commit e37663e5661ac12b7edb2a52951d5bff98b9d1d3
Author: simonpipe
Date: Thu May 28 08:13:15 2026 +0200
Upload files to "/"
diff --git a/extension.ini b/extension.ini
new file mode 100644
index 0000000..54bc1c0
--- /dev/null
+++ b/extension.ini
@@ -0,0 +1,14 @@
+# Datenstrom Yellow extension settings
+
+Extension: Mastodonapi
+Version: 0.9.8
+Description: Import your toots into your blog.
+Developer: Simon Rohr
+Tag: feature
+DownloadUrl: https://codeberg.org/simonpipe/yellow-mastodonapi/archive/main.zip
+DocumentationUrl: https://codeberg.org/simonpipe/yellow-mastodonapi
+DocumentationLanguage: en, de
+Published: 2026-05-27 14:20:00
+Status: experimental
+system/workers/mastodonapi.php: mastodonapi.php, create, update
+system/workers/mastodonapi.status: mastodonapi.status, create, update, careful
\ No newline at end of file
diff --git a/externallinks.php b/externallinks.php
new file mode 100644
index 0000000..6fd9d79
--- /dev/null
+++ b/externallinks.php
@@ -0,0 +1,296 @@
+yellow = $yellow;
+ $this->yellow->system->setDefault("mastodonapiInstanceUrl", "https://example.social");
+ $this->yellow->system->setDefault("mastodonapiAccountId", "");
+ $this->yellow->system->setDefault("mastodonapiAccessToken", "");
+ $this->yellow->system->setDefault("mastodonapiUpdateInterval", "30");
+ $this->yellow->system->setDefault("mastodonapiLimit", "20");
+ $this->yellow->system->setDefault("mastodonapiHashtag", "");
+ $this->yellow->system->setDefault("mastodonapiAuthor", "YellowUser");
+ $this->yellow->system->setDefault("mastodonapiTag", "Fediverse");
+ $this->yellow->system->setDefault("mastodonapiFolder", "content/2-blog/");
+ $this->yellow->system->setDefault("mastodonapiMaxAge", "0");
+ $this->yellow->system->setDefault("mastodonapiStatus", "public");
+ }
+
+ // Handle update
+ public function onUpdate($action) {
+ if ( $action=="mastodon" || $action=="daily" || $action=="install" ) {
+ $this->updateMastodonPosts();
+ }
+ }
+
+ // Handle command
+ public function onCommand($command, $text) {
+ if ( $command=="mastodonapi" ) {
+ echo "Mastodon Import gestartet...\n";
+ $count = $this->updateMastodonPosts();
+ if ( $count===false ) {
+ echo "Fehler: API nicht erreichbar, falsche ID oder Zugriff verweigert.\n";
+ return 500;
+ }
+ echo "Erfolg: " . $count . " neue Posts verarbeitet.\n";
+ return true;
+ }
+ return 0;
+ }
+
+ // Handle periodic update
+ public function onTick() {
+ $interval = intval($this->yellow->system->get("mastodonapiUpdateInterval")) * 60;
+ $statusFile = $this->getStatusFile();
+ $lastUpdate = file_exists($statusFile) ? intval(file_get_contents($statusFile)) : 0;
+ if ( time() - $lastUpdate >= $interval ) {
+ $this->updateMastodonPosts();
+ }
+ }
+
+ // Update Mastodon posts
+ public function updateMastodonPosts() {
+ $postsCreated = $this->processMastodonFeed();
+ if ( $postsCreated!==false ) {
+ $statusFile = $this->getStatusFile();
+ file_put_contents($statusFile, time());
+ }
+ return $postsCreated;
+ }
+
+ // Process Mastodon feed and create pages
+ private function processMastodonFeed() {
+ $instanceUrl = $this->yellow->system->get("mastodonapiInstanceUrl");
+ $accountId = $this->yellow->system->get("mastodonapiAccountId");
+ $accessToken = $this->yellow->system->get("mastodonapiAccessToken");
+ $hashtag = ltrim($this->yellow->system->get("mastodonapiHashtag"), "#");
+ $defaultAuthor = $this->yellow->system->get("mastodonapiAuthor");
+ $defaultTag = $this->yellow->system->get("mastodonapiTag");
+ $targetFolder = $this->yellow->system->get("mastodonapiFolder");
+ $statusSetting = $this->yellow->system->get("mastodonapiStatus");
+ $maxAgeHours = intval($this->yellow->system->get("mastodonapiMaxAge"));
+ $limit = intval($this->yellow->system->get("mastodonapiLimit"));
+
+ if ( empty($instanceUrl) || empty($accountId) ) return false;
+
+ $apiUrl = rtrim($instanceUrl, "/") . "/api/v1/accounts/" . $accountId . "/statuses?limit=" . $limit;
+
+ $headers = array("User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) YellowMastodonapi/" . self::VERSION);
+ if ( !empty($accessToken) ) $headers[] = "Authorization: Bearer " . $accessToken;
+
+ $ch = curl_init($apiUrl);
+ curl_setopt_array($ch, array(
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_FOLLOWLOCATION => true,
+ CURLOPT_TIMEOUT => 30,
+ CURLOPT_HTTPHEADER => $headers,
+ CURLOPT_SSL_VERIFYPEER => false,
+ CURLOPT_SSL_VERIFYHOST => 0
+ ));
+ $json = curl_exec($ch);
+ $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ curl_close($ch);
+
+ if ( $json===false || $httpCode < 200 || $httpCode >= 300 ) return false;
+ $posts = json_decode($json, true);
+ if ( !is_array($posts) ) return false;
+
+ echo "DEBUG: " . count($posts) . " Posts von API empfangen.\n";
+
+ $postsCreated = 0;
+ foreach ( $posts as $index => $post ) {
+ // Snac-Verschachtelung auflösen: Wenn ein echtes 'reblog'-Objekt von jemand anderem drinsteckt
+ if ( !empty($post["reblog"]) && is_array($post["reblog"]) ) {
+ // Nur überspringen, wenn es wirklich der Post eines ANDEREN Nutzers ist, den du geteilt hast
+ if ( !empty($post["reblog"]["account"]["id"]) && $post["reblog"]["account"]["id"] != $accountId ) {
+ echo "DEBUG [$index]: Übersprungen (Echter Reblog eines fremden Beitrags)\n";
+ continue;
+ }
+ // Wenn es dein eigener ist, nutzen wir die inneren Daten
+ $post = $post["reblog"];
+ }
+
+ $contentHtml = $post["content"] ?? "";
+ $createdAt = $post["created_at"] ?? "";
+ $shortText = mb_substr(strip_tags($contentHtml), 0, 40) . "...";
+
+ // Reblogged-Flag nur werten, wenn es kein Eigen-Boost ist
+ if ( (isset($post["reblogged"]) && $post["reblogged"]===true) && empty($contentHtml) ) {
+ echo "DEBUG [$index]: Übersprungen (Leerer Boost): $shortText\n";
+ continue;
+ }
+
+ // REPLY CHECK (Snac sendet leere Strings oder null)
+ if ( !empty($post["in_reply_to_id"]) ) {
+ echo "DEBUG [$index]: Übersprungen (Antwort - Reply-ID: " . $post["in_reply_to_id"] . "): $shortText\n";
+ continue;
+ }
+
+ // ALTER CHECK
+ if ( $maxAgeHours > 0 ) {
+ if ( strtotime($createdAt) < ( time() - ( $maxAgeHours * 3600 ) ) ) {
+ echo "DEBUG [$index]: Übersprungen (Zu alt - Datum: $createdAt): $shortText\n";
+ continue;
+ }
+ }
+
+ // HASHTAG CHECK
+ $tags = array_column($post["tags"] ?? array(), "name");
+ if ( !empty($hashtag) && !in_array(strtolower($hashtag), array_map("strtolower", $tags)) ) {
+ echo "DEBUG [$index]: Übersprungen (Hashtag #$hashtag fehlt): $shortText\n";
+ continue;
+ }
+
+ $title = $this->generateTitle($contentHtml);
+ $titleSlug = $this->generateTitleSlug($title);
+ $fileName = date("Y-m-d", strtotime($createdAt)) . "-" . $titleSlug . ".md";
+ $fullPath = $this->yellow->system->get("coreServerDocument") . $targetFolder . $fileName;
+
+ // EXISTENZ CHECK
+ if ( file_exists($fullPath) ) {
+ echo "DEBUG [$index]: Übersprungen (Datei existiert bereits: $fileName): $shortText\n";
+ continue;
+ }
+
+ if ( !is_dir(dirname($fullPath)) ) {
+ mkdir(dirname($fullPath), 0777, true);
+ chmod(dirname($fullPath), 0777);
+ }
+
+ $imageFilenames = array();
+ $imageAltTexts = array();
+ foreach ( $post["media_attachments"] ?? array() as $media ) {
+ if ( ( $media["type"] ?? "" )==="image" ) {
+ $filename = $this->downloadAndSaveImage($media, $this->yellow->system->get("coreServerDocument") . "media/images/");
+ if ( $filename ) {
+ $imageFilenames[] = $filename;
+ $imageAltTexts[] = $media["description"] ?? "";
+ }
+ }
+ }
+
+ $authorName = !empty($post["account"]["display_name"]) ? $post["account"]["display_name"] : $defaultAuthor;
+ $authorUrl = !empty($post["account"]["url"]) ? $post["account"]["url"] : "#";
+
+ $markdownContent = $this->generateMarkdown(
+ $title, $titleSlug, $createdAt, $contentHtml,
+ $authorName, $authorUrl,
+ $defaultAuthor, $defaultTag, $imageFilenames, $imageAltTexts, $statusSetting
+ );
+
+ file_put_contents($fullPath, $markdownContent);
+ chmod($fullPath, 0666);
+ echo "-> ERSTELLT [$index]: $fileName ($shortText)\n";
+ $postsCreated++;
+ }
+ return $postsCreated;
+ }
+
+ // Download and save image
+ private function downloadAndSaveImage($media, $dirPath) {
+ $imageUrl = $media['remote_url']
+ ?? $media['url']
+ ?? $media['preview_url']
+ ?? ($media['meta']['original']['url'] ?? null)
+ ?? ($media['meta']['small']['url'] ?? null);
+
+ if (empty($imageUrl)) return false;
+
+ if (!is_dir($dirPath)) mkdir($dirPath, 0755, true);
+
+ $fileName = basename(parse_url($imageUrl, PHP_URL_PATH));
+
+ if (!strpos($fileName, '.')) {
+ $fileName .= ".jpg";
+ }
+
+ $fullPath = $dirPath . $fileName;
+
+ if (file_exists($fullPath) && filesize($fullPath) > 0) return $fileName;
+
+ $ch = curl_init($imageUrl);
+ $headers = ["User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) YellowMastodonAPI"];
+
+ $accessToken = $this->yellow->system->get("mastodonapiAccessToken");
+ if (!empty($accessToken)) {
+ $headers[] = "Authorization: Bearer " . $accessToken;
+ }
+
+ curl_setopt_array($ch, [
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_FOLLOWLOCATION => true,
+ CURLOPT_TIMEOUT => 30,
+ CURLOPT_HTTPHEADER => $headers,
+ CURLOPT_SSL_VERIFYPEER => false,
+ CURLOPT_SSL_VERIFYHOST => 0
+ ]);
+
+ $imgData = curl_exec($ch);
+ $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ curl_close($ch);
+
+ if ($imgData === false || $httpCode < 200 || $httpCode >= 300) {
+ return false;
+ }
+
+ file_put_contents($fullPath, $imgData);
+ return $fileName;
+ }
+
+ // Generate title from content
+ private function generateTitle($content) {
+ $text = trim(strip_tags($content));
+ $text = preg_replace("/https?:\/\/[^\s]+|#\w+/u", "", $text);
+ $words = explode(" ", $text);
+ return trim(mb_substr(implode(" ", array_slice($words, 0, 7)), 0, 100, "UTF-8")) ?: "Mastodon Post";
+ }
+
+ // Generate slug from title
+ private function generateTitleSlug($title) {
+ $slug = mb_strtolower($title, "UTF-8");
+ $slug = str_replace(array("ä","ö","ü","ß"," "), array("ae","oe","ue","ss","-"), $slug);
+ return trim(preg_replace("/[^a-z0-9-]+/", "", $slug), "-");
+ }
+
+ // Generate markdown content
+ private function generateMarkdown($title, $slug, $dateTime, $html, $origAuthor, $origUrl, $yellowAuthor, $yellowTag, $images, $alts, $status) {
+ $date = new DateTime($dateTime);
+ $pub = $date->format("Y-m-d H:i:s");
+ $content = trim(html_entity_decode(strip_tags(str_replace(array("
", "
"), array("\n", "\n"), $html)), ENT_QUOTES | ENT_HTML5, "UTF-8"));
+ $content = str_replace(array("\n", "#"), array("\n> ", "#"), $content);
+ $imageMarkdown = "";
+ $featuredImage = $featuredImageAlt = "";
+ foreach ( $images as $i => $filename ) {
+ $alt = isset($alts[$i]) ? htmlspecialchars($alts[$i], ENT_QUOTES) : "";
+ $imageMarkdown .= "\n
\n";
+ if ( $i===0 ) {
+ $featuredImage = "Image: $filename\n";
+ $featuredImageAlt = "ImageAlt: $alt\n";
+ }
+ }
+ $authorSafe = str_replace("@", "@", $origAuthor);
+ return "---\n" .
+ "Title: $title\n" .
+ "TitleSlug: $slug\n" .
+ "Published: $pub\n" .
+ "Author: $yellowAuthor\n" .
+ "Layout: blog\n" .
+ "Tag: $yellowTag\n" .
+ "Status: $status\n" .
+ $featuredImage . $featuredImageAlt .
+ "---\n\n" .
+ $imageMarkdown . "\n" .
+ "> " . $content . "\n>\n" .
+ "> — $authorSafe";
+ }
+
+ // Return status file path
+ private function getStatusFile() {
+ return $this->yellow->system->get("coreWorkerDirectory") . "mastodonapi.status";
+ }
+}
\ No newline at end of file