Mit Raycast bei Kirby und Mastodon posten
In dieser Anleitung entwickeln wir ein Raycast-Plugin, mit dem wir vom Desktop aus eine Notiz erstellen können, die nicht nur auf der eigenen Seite, sondern auch bei Mastodon und/oder Bluesky veröffentlicht wird.
Mit nur einem Tastenkürzel werden wir jederzeit Zugriff auf das Formular in Raycast haben. Es wird ausreichen, einen Texte einzugeben und das Formular abzuschicken, um im Kirby-CMS eine Notiz oder ein Lesezeichen zu erstellen und diese von Kirby aus auf andere Plattformen zu verteilen (POSSE).
POSSE ist in diesem Fall keine komische Darbietung, sondern steht für: Publish (on your) Own Site, Syndicate Elsewhere. Es geht also darum, immer zuerst auf der eigenen Seite zu veröffentlichen und von dort zu verteilen.
Auf dem Mac werden wir dafür Raycast benutzen. Raycast erlaubt es uns, schnell Befehle auszuführen, diesen Befehlen Tastenkürzel und einen Alias zuzuweisen.
Ich möchte auf meinem Mac die Tastenkombination SUPER+N
drücken, um dieses Fenster zu öffnen und eine neue Notiz zu erstellen:

Hier kann ich einen kurzen Text eingeben, wenn ich ein Lesezeichen erstellen möchte, eine URL, einen optionalen Seitentitel und schließlich kann ich noch festlegen, ob der Beitrag sofort veröffentlicht werden soll, ob er ebenfalls bei Mastodon gepostet werden soll und ob eine URL an den Post bei Mastodon gehangen werden soll.
Was benötigen wir?
- Ein Kirby-Plugin, das eine Route bereitstellt, die eine neue Notiz anlegt
- Ein Raycast-Plugin, welches eine schnelle Eingabe erlaubt und die Kirby-Route aufruft
- Das IndieConnector-Plugin, welches den POSSE-Teil übernimmt
Grundlagen
Auf meiner Webseite unterscheide ich zwischen zwei Arten von Notizen:
- Die einfache Textnotiz
- Ein Lesezeichen
Beide benutzen unterschiedliche Templates und verhalten sich auf der Seite unterschiedlich.
Um Beiträge automatisch bei Mastodon zu posten, verwende ich das IndieConnector-Plugin. Die Installation erfolgt in unserem Beispiel über Composer. Im Hauptverzeichnis von Kirby rufen wir auf:
composer require mauricerenck/indieconnector
Damit ist der IndieConnector installiert und kann konfiguriert werden. Dazu wechseln wir in die Datei sites/config/config.php
und nehmen folgende Einstellungen vor:
'mauricerenck.indieConnector' => [
'secret' => 'my-secret',
'sqlitePath' => './content/.db/',
'stats' => [
'enabled' => true,
],
'post' => [
'prefereLanguage' => 'en',
'allowedTemplates' => ['bookmark', 'note'],
'textfields' => ['text'],
],
'mastodon' => [
'enabled' => true,
'instance-url' => 'https://example.com',
'bearer' => 'my_bearer',
],
],
Als Erstes setzen wir ein Secret, das an verschiedenen Stellen im Plugin gebraucht wird, um Routen und Webhooks abzusichern.
Wir wollen die Panel-Statistiken und später ggf. ein paar andere Funktionen nutzen, die eine Datenbank benötigen, deshalb müssen wir einen Pfad konfigurieren, in dem die Datenbank abgelegt werden kann.
Als Nächstes konfigurieren wir die allgemeinen Einstellungen, um zu Mastodon und anderen Services posten zu können. Ich setze die bevorzugte Sprache noch auf Englisch, weil ich in Deutsch und Englisch schreibe und obwohl Deutsch die Standardsprache in Kirby ist, poste ich auf Englisch.
Wie oben beschrieben, habe ich zwei Templates für meine Notizen, nur diese beiden Templates sollen ein Posting bei Mastodon auslösen. Also setze ich allowedTemplates
auf diese beiden Templatenamen. Abschließend sage ich dem Plugin noch, in welchem Feld mein Text gespeichert ist. In unserem Beispiel text
.
Jetzt noch schnell Mastodon konfigurieren. Hierzu setzen wir die Instanz und einen API-Token, den man unter https://example.com/settings/applications
erzeugen kann. Die neue App benötigt mindestens die folgenden Scopes:
- read
- write:media
- write:statuses
Den erzeugten Token sollten wir irgendwo ablegen, wo er sicher ist.
Ab sofort wird das IndieConnector-Plugin einen Mastodonbeitrag erstellen, sobald wir eine neue Notiz in Kirby veröffentlichen.
Raycast Plugin
Raycast-Erweiterungen werden in TypeScript geschrieben, die UI-Komponenten in React. Die Erweiterung, die wir schreiben werden, besteht aus einer Mischung. Wir benötigen einerseits das Formular, in dem wir den Beitrag schreiben können und wir müssen diesen Beitrag natürlich in Richtung Kirby schicken.
Ich werde hier nicht erklären, wie man eine neue Erweiterung erstellt, das lässt sich hier nachlesen und ist auch relativ leicht. Ich gehe an dieser Stelle davon aus, dass es bereits ein Dummy-Plugin gibt. Ich veröffentliche außerdem meinen Code bei GitHub.
Sobald wir die Entwicklung mit npm run dev
starten, taucht das neue Plugin bei Raycast auf und kann getestet werden.
Wir beginnen mit dem simplen Teil, dem Formular. Wir legen ein neues Command an, wenn das bisher nicht geschehen ist, und geben das Formular aus:
export default function Command() {
return (
<Form
actions={
<ActionPanel>
<Action.SubmitForm onSubmit={handleSubmit} />
</ActionPanel>
}
>
<Form.TextArea id="posttext" title="Post Text" placeholder="Enter your text" enableMarkdown={true} />
<Form.TextField id="url" title="Bookmark" placeholder="https://" value={url} onChange={setUrl} />
<Form.TextField id="title" title="Titel" placeholder="Titel" />
<Form.Separator />
<Form.Checkbox id="autopublish" title="Autopublish" label="Autopublish" defaultValue={preferences.autopublish} />
<Form.Checkbox id="skipUrl" title="Skip URL" label="Skip URL" defaultValue={preferences.skipurl} />
<Form.Checkbox id="externalPost" title="External Post" label="External Post" defaultValue={preferences.posse} />
</Form>
);
}
Das sorgt für eine Darstellung wie oben im Screenshot. Woher die defaultValues
kommen, erkläre ich gleich. In der Form kannst du sehen, dass beim Abschicken handleSubmit()
aufgerufen wird. Hier findet der eigentliche API-Call statt:
async function handleSubmit(values: Values) {
try {
const baseUrlNoTrailingSlash = preferences.baseurl.replace(/\/$/, "");
const response = await fetch(`${baseUrlNoTrailingSlash}/posse`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${preferences.secret}`,
},
body: JSON.stringify(values),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const responseData = (await response.json()) as ApiResponse;
await Clipboard.copy(responseData.url);
const status = responseData.status == "draft" ? "Posted as draft" : "Published your post";
showToast({ title: status, message: "The URL has been copied to your clipboard" });
await delay(1000);
await closeMainWindow();
} catch (error) {
console.error(error);
showToast({ style: Toast.Style.Failure, title: "Error", message: "Failed to submit form" });
}
}
Wir rufen die API mit fetch auf und greifen dabei auf zwei Werte zu, die später in den Einstellungen der Erweiterung festgelegt werden können, der Basis-URL und einem Secret. Auf diese Weise vermeiden wir das Hardcodieren privater Daten im Quellcode.
Wir setzen einen POST
-Call ab und reichen diesem 1:1 die Werte rein, die wir aus dem Formular bekommen.
Läuft etwas schief, erzeugen wir einen Fehler, der unten im catch
Block für eine Fehlermeldung unter dem Formular sorgt.
Läuft alles gut, lesen wir die Antwort der API aus, diese wird eine URL zum neuen Beitrag enthalten, welche wir prompt in die Zwischenablage kopieren, dem User das mitteilen und dann nach einer Sekunde das Fenster schließen.
Ich habe noch eine kleine Feinheit ergänzt, die beim Aufruf der Erweiterung in Form eines Hooks stattfindet:
const [url, setUrl] = useState("");
// Read clipboard when the form is opened
useEffect(() => {
async function fetchClipboard() {
const text = await Clipboard.readText();
if (text) {
if (text.startsWith("http")) {
setUrl(text);
}
}
}
fetchClipboard();
}, []);
Sobald das Formular angezeigt wird, lese ich die Zwischenablage aus und prüfe, ob sie einen Link enthält. Wenn dem so ist, setze ich den lokalen State url
. Ein Blick in den Code des Formulars zeigt, dass diese URL dann als Value für das Bookmark-Feld gesetzt wird. Denn oftmals kopiere ich eine URL im Browser, um sie dann direkt zu teilen. Mit dieser kleinen Feinheit spare ich mir dann ein paar Tastenkürzel.
React Hooks
React Hook werden in bestimmten Situationen aufgerufen. In diesem Fall wird der Hook beim Rendern des Formular aufgerufen und der enthaltene Code ausgeführt.
React States
Ein React State wird immer mit einem Variablennamen und einem Setter definiert. Die Variable wird künftig nur über den Setter aktualisiert. Überall dort, wo die Variable verwendet wird, wird automatisch ein Update/Render ausgelöst, wenn sich deren Wert ändert.
Damit könnten wir jetzt eigentlich arbeiten, noch fehlen uns aber alle Werte aus den Einstellungen, also alles, was unter preferences
gespeichert ist. Hier der passende Typ zu besseren Übersicht:
interface Preferences {
secret: string;
baseurl: string;
autopublish: boolean;
posse: boolean;
skipurl: boolean;
}
Bevor wir etwas anderes im Command tun, holen wir uns die Einstellungen:
const preferences = getPreferenceValues<Preferences>();
const markdown = "You need to provide a secret in the extension preferences.";
if (!preferences.secret) {
return (
<Detail
markdown={markdown}
actions={
<ActionPanel>
<Action title="Open Extension Preferences" onAction={openExtensionPreferences} />
</ActionPanel>
}
/>
);
}
Einstellungen
Einstellungen können auf zwei Ebenen abgelegt werden, die wir beide nutzen:
- Global für alle Commands
- Für ein spezielles Command
Die Preferences werden in der package.json
definiert.
baseUrl
und secret
werden wir global ablegen. Sollten wir einmal weitere API-Calls hinzufügen, benötigen wir sie dort auch. Alle anderen Einstellungen brauchen wir nur im Formular und werden sie deshalb nur für das Command ablegen.
Auf oberster Ebene der package.json
legen wir einen neuen Eintrag an und definieren die Werte:
"preferences": [
{
"name": "secret",
"title": "Secret key",
"description": "Enter your API token",
"type": "password",
"required": true
},
{
"name": "baseurl",
"title": "Base URL",
"description": "Enter your API endpoint",
"type": "textfield",
"required": true
}
],
Rufen wir die Erweiterung das erste Mal auf, müssen wir zunächst Werte für diese globalen Einstellungen eingeben. Damit sind diese auf jeden Fall gesetzt.
Nun definieren wir Einstellungen, die für unser Command gelten und für mögliche weitere Kommandos nicht von Nutzen sind:
"commands": [
{
"name": "new-note",
"title": "New Note",
"description": "Creates a new note",
"mode": "view",
"preferences": [
{
"name": "autopublish",
"title": "Autopublish",
"description": "Automatically publish the note",
"type": "checkbox",
"default": false
},
{
"name": "skipurl",
"title": "Skip URL",
"description": "Skip URL",
"type": "checkbox",
"default": true
},
{
"name": "posse",
"title": "POSSE",
"description": "Post to other platforms",
"type": "checkbox",
"default": true
}
]
}
],
Dieses Mal sind wir nicht ganz so streng. Alle Einstellungen habe einen Standardwert, sie müssen also nicht zwingen konfiguriert werden. Wir benutzen diese Einstellungen für die drei Checkboxen im Formular. So muss ich beispielsweise nicht jedes Mal anklicken, dass mein Beitrag automatisch veröffentlicht werden soll.
Im Formular greifen wir bereits mit defaultValue={preferences.posse}
auf die Werte zu.
Sobald du npm run dev
beendest, steht das Plugin dir normal in Raycast zur Verfügung und du kannst es wie andere Plugins auch nutzen.
Damit sind wir startklar. Uns fehlt allerdings noch die Empfangsseite in Form einer Kirby-Route.
Die Kirby-Route
Wir haben mehrere Möglichkeiten, die Kirby-Route anzulegen:
- Direkt in der config.php
- Als Plugin
Da der Code identisch ist, überlasse ich diese Entscheidung dir. Ich habe ein Plugin, in dem solche speziellen Routen sammle, damit meine config.php übersichtlich(er) bleibt. Der Code ist aber in beiden Fällen derselbe.
'routes' => [
[
'pattern' => 'posse',
'method' => 'POST',
'action' => function () {
// FOLLOWING CODE
}
]
]
Die Route soll unter /posse
aufgerufen werden, und zwar als POST
-Request. Bevor wir etwas machen, prüfen wir, ob der Token korrekt ist. Wir holen uns die Daten aus der Anfrage, die im POST-Request übermittelt wurden. Das machen wir mit den Kirby Request-Methoden1. Dort schauen wir, ob ein Authorization Header mit unserem Token vorhanden ist:
$request = kirby()->request();
$requestData = $request->data();
$authHeader = $request->header('Authorization');
$token = $authHeader ? explode(' ', $authHeader)[1] : null;
if ($token !== option('mauricerenck.posse.token', '')) {
return new Response('Unauthorized', 'text/plain', 401);
}
In der config.php
müssen wir nun unser Token festlegen. Stimmt das Token aus der URL nicht überein, brechen wir sofort ab und geben eine entsprechende Rückmeldung. Um ganz sicherzugehen, setzen wir den Standardwert des Tokens auf einen leeren Text. Stimmt das Token, läuft das Skript weiter.
Damit wir eine neue Notiz anlegen können, benötigen wir erst einmal eine Seite, unter der wir dies machen können. In meinem Fall gibt es die Seite notes
, die wiederum in Jahre unterteilt ist. Wir versuchen uns, diese Seite zu holen, und sollte sie nicht gefunden werden, brechen wir sofort ab:
$year = date('Y');
$parent = kirby()->page('notes/' . $year);
if (is_null($parent)) {
return new Response('Not Found', 'text/plain', 404);
}
Im folgenden Verlauf können wir sicher sein, dass alle Voraussetzungen erfüllt sind.
Im nächsten Schritt schnappen wir uns die übermittelten Werte und setzen nötige Variablen und fangen dabei gleich möglicherweise fehlende Werte ab:
$template = 'note';
$autoPublish = isset($requestData['autopublish']) && filter_var($requestData['autopublish'], FILTER_VALIDATE_BOOLEAN);
$newData = [
'title' => empty($requestData['title']) ? 'Bookmark ' . date('Y-m-d') : trim($requestData['title']),
'text' => !empty($requestData['posttext']) ? trim($requestData['posttext']) : '',
'icSkipUrl' => isset($requestData['skipUrl']) && filter_var($requestData['skipUrl'], FILTER_VALIDATE_BOOLEAN),
'enableExternalPosting' => isset($requestData['externalPost']) && filter_var($requestData['externalPost'], FILTER_VALIDATE_BOOLEAN)
];
In meinem Beispiel unterscheiden wir zwischen zwei Templates, einer einfachen Textnotiz note
und einem Lesezeichen bookmark
. Zunächst setzen wir das Standard-Template note
.
Wir empfangen alle Daten als String, das trifft auch auf Boolesche Werte zu. Mit $autoPublish = isset($requestData['autopublish']) && filter_var($requestData['autopublish'], FILTER_VALIDATE_BOOLEAN);
können wir den Wert sicher in eine echte Boolesche Variable umwandeln. Das machen wir für alle passenden Werte.
Unsere Kirby-Seite benötigt einen Titel. Wurde in Raycast kein Titel eingeben, setzen wir einen generierten Titel.
Der eigentliche Text wird im Normalfall vorhanden sein, aber wir stellen trotzdem sicher, dass zumindest ein leerer Text hinterlegt wird.
Wir setzen außerdem zwei Werte des IndieConnectors, nämlich ob die URL zur Notiz an den Mastodon-Post gehängt werden soll und ob wir überhaupt bei Mastodon (oder Bluesky) posten wollen.
Danach prüfen wir, ob eine gültige URL übermittelt wurde. Wenn ja, unterscheide ich an dieser Stelle zwischen einer reinen Textnotiz und einem Lesezeichen. Das spiegelt sich im Titel, dem Link und dem Template wider:
if (!empty($url) && V::url($url)) {
$newData['title'] = 'Link: ' . $newData['title'];
$newData['link'] = $url;
$template = 'bookmark';
}
Bevor wir gleich die Seite anlegen, müssen wir noch eine Sache sicherstellen: Wir müssen Duplikate vermeiden. Erstellen wir eine Notiz mit dem Titel test
und tun dies ein zweites Mal, wirft uns Kirby ein Duplikat um die Ohren und legt die zweite Seite nicht an, weil sie die erste überschreiben würde. Das müssen wir also abfangen:
$slug = Str::slug($newData['title']);
$unusedSlug = false;
while ($unusedSlug === false) {
$unusedSlug = is_null($parent->childrenAndDrafts()->find($slug));
if (!$unusedSlug) {
$slug = $slug . '-' . uniqid();
}
}
Wir erzeugen den Slug für die neue Seite, dieser entspricht später dem Ordnernamen. Dann durchlaufen wir eine Schleife und prüfen darin, ob es bereits eine Seite mit diesem Slug gibt. Wenn ja, ergänzen wir eine ID. Beim zweiten Durchlauf sollte dann keine Seite mehr gefunden werden und wir sind auf der sicheren Seite. Sollte wider Erwarten doch eine Seite existieren, läuft die Schleife so lange, bis das nicht mehr der Fall ist.
Jetzt können wir endlich die neue Seite erstellen. Dazu benötigen wir die entsprechenden Berechtigungen, die wir uns mit impersonate()
holen:
kirby()->impersonate('kirby');
$newPage = Page::create([
'parent' => $parent,
'slug' => $slug,
'template' => $template,
'content' => $newData
]);
Neue Seiten sind zunächst immer ein Entwurf. Wenn wir bei Raycast angegeben haben, dass die Seite sofort publiziert werden soll, müssen wir das jetzt noch tun:
if ($autoPublish === true) {
$newPage->changeStatus('listed');
}
Wir sind fast fertig. Ganz zum Schluss wollen wir Raycast noch das Ergebnis mitteilen:
$response = [
'url' => $newPage->url(),
'status' => $setToPublished ? 'published' : 'draft',
];
return new Response(json_encode($response), 'application/json', 201);
Anhand dieser Antwort zeigen wir in Raycast eine Erfolgs- oder Fehlermeldung an. Und kopieren die URL der Notiz in die Zwischenablage.
Verfeinerungen
Mit unserem können wir jetzt in Windeseile neue Notizen erzeugen und diese auch direkt als Mastodon- oder Bluesky-Beitrag veröffentlichen und das mit einer überschaubaren Code-Basis.
Wer mag, kann jetzt noch in die Verfeinerung gehen. Wir könnten uns etwa Antworten bei Bluesky zurück in die Notizen holen. Dazu werde ich demnächst einen Beitrag veröffentlichen, denn der IndieConnector kann das bereits.
Den Quellcode für die komplette Kirby-Route findest du hier:
Den Quellcode für das Raycast-Plugin findest du hier:
Viel Spaß beim Posten!
Ich habe mich zunächst dagegen entschieden, beide Plugins offiziell zu veröffentlichen, weil gerade aufseiten von Kirby die Anpassungen sehr individuell sein können. Sollte aber Bedarf bestehen, schreib mir einen Kommentar!
Kommentar schreiben