diff --git a/composer.json b/composer.json index bb675c651..6e0d9f984 100644 --- a/composer.json +++ b/composer.json @@ -35,6 +35,7 @@ "friendica/json-ld": "^1.0", "geekwright/po": "^2.0", "guzzlehttp/guzzle": "^6.5", + "guzzlehttp/oauth-subscriber": "^0.6", "kornrunner/blurhash": "^1.2", "league/html-to-markdown": "^4.8", "level-2/dice": "^4", diff --git a/composer.lock b/composer.lock index 280e979e9..10fa75638 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "456d14e3ad9be265c5c9e6172a0d18d8", + "content-hash": "c208d7f5176358ea157f109c1c7d68dd", "packages": [ { "name": "asika/simple-console", @@ -967,6 +967,61 @@ ], "time": "2022-06-20T22:16:07+00:00" }, + { + "name": "guzzlehttp/oauth-subscriber", + "version": "0.6.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/oauth-subscriber.git", + "reference": "8d6cab29f8397e5712d00a383eeead36108a3c1f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/oauth-subscriber/zipball/8d6cab29f8397e5712d00a383eeead36108a3c1f", + "reference": "8d6cab29f8397e5712d00a383eeead36108a3c1f", + "shasum": "" + }, + "require": { + "guzzlehttp/guzzle": "^6.5|^7.2", + "guzzlehttp/psr7": "^1.7|^2.0", + "php": ">=5.5.0" + }, + "require-dev": { + "phpunit/phpunit": "~4.0|^9.3.3" + }, + "suggest": { + "ext-openssl": "Required to sign using RSA-SHA1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "0.6-dev" + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Subscriber\\Oauth\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + } + ], + "description": "Guzzle OAuth 1.0 subscriber", + "homepage": "http://guzzlephp.org/", + "keywords": [ + "Guzzle", + "oauth" + ], + "time": "2021-07-13T12:01:32+00:00" + }, { "name": "guzzlehttp/promises", "version": "1.5.2", diff --git a/src/Content/Text/BBCode.php b/src/Content/Text/BBCode.php index 93dbb1176..715a2ba8f 100644 --- a/src/Content/Text/BBCode.php +++ b/src/Content/Text/BBCode.php @@ -57,6 +57,7 @@ class BBCode const DIASPORA = 3; const CONNECTORS = 4; const TWITTER_API = 5; + const NPF = 6; const OSTATUS = 7; const TWITTER = 8; const BACKLINK = 8; @@ -1355,7 +1356,9 @@ class BBCode /// @todo Have a closer look at the different html modes // Handle attached links or videos - if (in_array($simple_html, [self::MASTODON_API, self::TWITTER_API, self::ACTIVITYPUB])) { + if ($simple_html == self::NPF) { + $text = self::removeAttachment($text); + } elseif (in_array($simple_html, [self::MASTODON_API, self::TWITTER_API, self::ACTIVITYPUB])) { $text = self::replaceAttachment($text); } elseif (!in_array($simple_html, [self::INTERNAL, self::EXTERNAL, self::CONNECTORS])) { $text = self::replaceAttachment($text, true); @@ -1605,7 +1608,18 @@ class BBCode // Simplify "video" element $text = preg_replace('(\[video[^\]]*?\ssrc\s?=\s?([^\s\]]+)[^\]]*?\].*?\[/video\])ism', '[video]$1[/video]', $text); - if ($try_oembed) { + if ($simple_html == self::NPF) { + $text = preg_replace( + "/\[video\](.*?)\[\/video\]/ism", + '

', + $text + ); + $text = preg_replace( + "/\[audio\](.*?)\[\/audio\]/ism", + '

', + $text + ); + } elseif ($try_oembed) { // html5 video and audio $text = preg_replace( "/\[video\](.*?\.(ogg|ogv|oga|ogm|webm|mp4).*?)\[\/video\]/ism", diff --git a/src/Content/Text/NPF.php b/src/Content/Text/NPF.php new file mode 100644 index 000000000..2f0b36083 --- /dev/null +++ b/src/Content/Text/NPF.php @@ -0,0 +1,616 @@ +. + * + */ + +namespace Friendica\Content\Text; + +use DOMDocument; +use DOMElement; +use Friendica\Model\Photo; +use Friendica\Model\Post; + +/** + * Tumblr Neue Post Format + * @see https://www.tumblr.com/docs/npf + */ +class NPF +{ + private static $heading_subtype = []; + + /** + * Convert BBCode into NPF (Tumblr Neue Post Format) + * + * @param string $bbcode + * @param integer $uri_id + * @return array NPF + */ + public static function fromBBCode(string $bbcode, int $uri_id): array + { + $bbcode = self::prepareBody($bbcode); + + $html = BBCode::convert($bbcode, false, BBCode::NPF); + if (empty($html)) { + return []; + } + + $doc = new DOMDocument(); + + $doc->formatOutput = true; + if (!@$doc->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'))) { + return []; + } + + self::setHeadingSubStyles($doc); + + $element = $doc->getElementsByTagName('body')->item(0); + + list($npf, $text, $formatting) = self::routeChildren($element, $uri_id, true, []); + + return self::addLinkBlockForUriId($uri_id, 0, $npf); + } + + /** + * Fetch the heading types + * + * @param DOMDocument $doc + * @return void + */ + private static function setHeadingSubStyles(DOMDocument $doc) + { + self::$heading_subtype = []; + foreach (['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] as $element) { + if ($doc->getElementsByTagName($element)->count() > 0) { + if (empty(self::$heading_subtype)) { + self::$heading_subtype[$element] = 'heading1'; + } else { + self::$heading_subtype[$element] = 'heading2'; + } + } + } + } + + /** + * Prepare the BBCode for the NPF conversion + * + * @param string $bbcode + * @return string + */ + private static function prepareBody(string $bbcode): string + { + $shared = BBCode::fetchShareAttributes($bbcode); + if (!empty($shared)) { + $bbcode = $shared['shared']; + } + + $bbcode = preg_replace("/\[img\=([0-9]*)x([0-9]*)\](.*?)\[\/img\]/ism", '[img]$3[/img]', $bbcode); + + if (preg_match_all("#\[url=([^\]]+?)\]\s*\[img=([^\[\]]*)\]([^\[\]]*)\[\/img\]\s*\[/url\]#ism", $bbcode, $pictures, PREG_SET_ORDER)) { + foreach ($pictures as $picture) { + if (preg_match('#/photo/.*-[01]\.#ism', $picture[2]) && (preg_match('#/photo/.*-0\.#ism', $picture[1]) || preg_match('#/photos/.*/image/#ism', $picture[1]))) { + $bbcode = str_replace($picture[0], "\n\n[img=" . str_replace('-1.', '-0.', $picture[2]) . "]" . $picture[3] . "[/img]\n\n", $bbcode); + } + } + } + + $bbcode = preg_replace("/\[img\=(.*?)\](.*?)\[\/img\]/ism", "\n\n[img=$1]$2[/img]\n\n", $bbcode); + + if (preg_match_all("#\[url=([^\]]+?)\]\s*\[img\]([^\[]+?)\[/img\]\s*\[/url\]#ism", $bbcode, $pictures, PREG_SET_ORDER)) { + foreach ($pictures as $picture) { + if (preg_match('#/photo/.*-[01]\.#ism', $picture[2]) && (preg_match('#/photo/.*-0\.#ism', $picture[1]) || preg_match('#/photos/.*/image/#ism', $picture[1]))) { + $bbcode = str_replace($picture[0], "\n\n[img]" . str_replace('-1.', '-0.', $picture[2]) . "[/img]\n\n", $bbcode); + } + } + } + + $bbcode = preg_replace("/\[img\](.*?)\[\/img\]/ism", "\n\n[img]$1[/img]\n\n", $bbcode); + + do { + $oldbbcode = $bbcode; + $bbcode = str_replace(["\n\n\n"], ["\n\n"], $bbcode); + } while ($oldbbcode != $bbcode); + + return trim($bbcode); + } + + /** + * Walk recursively through the HTML + * + * @param DOMElement $element + * @param integer $uri_id + * @param boolean $parse_structure + * @param array $callstack + * @param array $npf + * @param string $text + * @param array $formatting + * @return array + */ + private static function routeChildren(DOMElement $element, int $uri_id, bool $parse_structure, array $callstack, array $npf = [], string $text = '', array $formatting = []): array + { + if ($parse_structure && $text) { + list($npf, $text, $formatting) = self::addBlock($text, $formatting, $npf, $callstack); + } + + $callstack[] = $element->nodeName; + $level = self::getLevelByCallstack($callstack); + + foreach ($element->childNodes as $child) { + switch ($child->nodeName) { + case 'b': + case 'strong': + list($npf, $text, $formatting) = self::addFormatting($child, $uri_id, 'bold', $callstack, $npf, $text, $formatting); + break; + + case 'i': + case 'em': + list($npf, $text, $formatting) = self::addFormatting($child, $uri_id, 'italic', $callstack, $npf, $text, $formatting); + break; + + case 's': + list($npf, $text, $formatting) = self::addFormatting($child, $uri_id, 'strikethrough', $callstack, $npf, $text, $formatting); + break; + + case 'u': + case 'span': + list($npf, $text, $formatting) = self::addFormatting($child, $uri_id, '', $callstack, $npf, $text, $formatting); + break; + + case 'hr': + case 'br': + if (!empty($text)) { + $text .= "\n"; + } + break; + + case '#text': + $text .= $child->textContent; + break; + + case 'table': + case 'summary': + // Ignore tables and spoilers + break; + + case 'a': + list($npf, $text, $formatting) = self::addInlineLink($child, $uri_id, $callstack, $npf, $text, $formatting); + break; + + case 'img': + $npf = self::addImageBlock($child, $uri_id, $level, $npf); + break; + + case 'audio': + case 'video': + $npf = self::addMediaBlock($child, $uri_id, $level, $npf); + break; + + default: + list($npf, $text, $formatting) = self::routeChildren($child, $uri_id, true, $callstack, $npf, $text, $formatting); + break; + } + } + + if ($parse_structure && $text) { + list($npf, $text, $formatting) = self::addBlock($text, $formatting, $npf, $callstack); + } + return [$npf, $text, $formatting]; + } + + /** + * Return the correct indent level + * + * @param array $callstack + * @return integer + */ + private static function getLevelByCallstack(array $callstack): int + { + $level = 0; + foreach ($callstack as $entry) { + if (in_array($entry, ['ol', 'ul', 'blockquote'])) { + ++$level; + } + } + return max(0, $level - 1); + } + + /** + * Detect the subtype via the HTML element callstack + * + * @param array $callstack + * @param string $text + * @return string + */ + private static function getSubTypeByCallstack(array $callstack, string $text): string + { + $subtype = ''; + foreach ($callstack as $entry) { + switch ($entry) { + case 'ol': + $subtype = 'ordered-list-item'; + break; + + case 'ul': + $subtype = 'unordered-list-item'; + break; + + case 'h1': + $subtype = self::$heading_subtype[$entry]; + break; + + case 'h2': + $subtype = self::$heading_subtype[$entry]; + break; + + case 'h3': + $subtype = self::$heading_subtype[$entry]; + break; + + case 'h4': + $subtype = self::$heading_subtype[$entry]; + break; + + case 'h5': + $subtype = self::$heading_subtype[$entry]; + break; + + case 'h6': + $subtype = self::$heading_subtype[$entry]; + break; + + case 'blockquote': + $subtype = mb_strlen($text) < 100 ? 'quote' : 'indented'; + break; + + case 'pre': + $subtype = 'indented'; + break; + + case 'code': + $subtype = 'chat'; + break; + } + } + return $subtype; + } + + /** + * Add formatting for a text block + * + * @param DOMElement $element + * @param integer $uri_id + * @param string $type + * @param array $callstack + * @param array $npf + * @param string $text + * @param array $formatting + * @return array + */ + private static function addFormatting(DOMElement $element, int $uri_id, string $type, array $callstack, array $npf, string $text, array $formatting): array + { + $start = mb_strlen($text); + + list($npf, $text, $formatting) = self::routeChildren($element, $uri_id, false, $callstack, $npf, $text, $formatting); + + if (!empty($type)) { + $formatting[] = [ + 'start' => $start, + 'end' => mb_strlen($text), + 'type' => $type + ]; + } + return [$npf, $text, $formatting]; + } + + /** + * Add an inline link for a text block + * + * @param DOMElement $element + * @param integer $uri_id + * @param array $callstack + * @param array $npf + * @param string $text + * @param array $formatting + * @return array + */ + private static function addInlineLink(DOMElement $element, int $uri_id, array $callstack, array $npf, string $text, array $formatting): array + { + $start = mb_strlen($text); + + list($npf, $text, $formatting) = self::routeChildren($element, $uri_id, false, $callstack, $npf, $text, $formatting); + + $attributes = []; + foreach ($element->attributes as $key => $attribute) { + $attributes[$key] = trim($attribute->value); + } + if (!empty($attributes['href'])) { + $formatting[] = [ + 'start' => $start, + 'end' => mb_strlen($text), + 'type' => 'link', + 'url' => $attributes['href'] + ]; + } + return [$npf, $text, $formatting]; + } + + /** + * Add a text block + * + * @param string $text + * @param array $formatting + * @param array $npf + * @param array $callstack + * @return array + */ + private static function addBlock(string $text, array $formatting, array $npf, array $callstack): array + { + $block = [ + 'type' => 'text', + 'subtype' => '', + 'text' => $text, + ]; + + if (!empty($formatting)) { + $block['formatting'] = $formatting; + } + + $level = self::getLevelByCallstack($callstack); + if ($level > 0) { + $block['indent_level'] = $level; + } + + $subtype = self::getSubTypeByCallstack($callstack, $text); + if ($subtype) { + $block['subtype'] = $subtype; + } else { + unset($block['subtype']); + } + + $npf[] = $block; + return [$npf, '', []]; + } + + /** + * Add a block for a preview picture + * + * @param array $media + * @param array $block + * @return array + */ + private static function addPoster(array $media, array $block): array + { + $poster = []; + if (!empty($media['preview'])) { + $poster['url'] = $media['preview']; + } + if (!empty($media['preview-width'])) { + $poster['width'] = $media['preview-width']; + } + if (!empty($media['preview-height'])) { + $poster['height'] = $media['preview-height']; + } + if (!empty($poster)) { + $block['poster'] = [$poster]; + } + return $block; + } + + /** + * Add a link block from the HTML attachment of a given post uri-id + * + * @param integer $uri_id + * @param integer $level + * @param array $npf + * @return array + */ + private static function addLinkBlockForUriId(int $uri_id, int $level, array $npf): array + { + foreach (Post\Media::getByURIId($uri_id, [Post\Media::HTML]) as $link) { + $host = parse_url($link['url'], PHP_URL_HOST); + if (in_array($host, ['www.youtube.com', 'youtu.be'])) { + $block = [ + 'type' => 'video', + 'provider' => 'youtube', + 'url' => $link['url'], + ]; + } elseif (in_array($host, ['vimeo.com'])) { + $block = [ + 'type' => 'video', + 'provider' => 'vimeo', + 'url' => $link['url'], + ]; + } elseif (in_array($host, ['open.spotify.com'])) { + $block = [ + 'type' => 'audio', + 'provider' => 'spotify', + 'url' => $link['url'], + ]; + } else { + $block = [ + 'type' => 'link', + 'url' => $link['url'], + ]; + if (!empty($link['name'])) { + $block['title'] = $link['name']; + } + if (!empty($link['description'])) { + $block['description'] = $link['description']; + } + if (!empty($link['author-name'])) { + $block['author'] = $link['author-name']; + } + if (!empty($link['publisher-name'])) { + $block['site_name'] = $link['publisher-name']; + } + } + + if ($level > 0) { + $block['indent_level'] = $level; + } + + $npf[] = self::addPoster($link, $block); + } + return $npf; + } + + /** + * Add an image block + * + * @param DOMElement $element + * @param integer $uri_id + * @param integer $level + * @param array $npf + * @return array + */ + private static function addImageBlock(DOMElement $element, int $uri_id, int $level, array $npf): array + { + $attributes = []; + foreach ($element->attributes as $key => $attribute) { + $attributes[$key] = trim($attribute->value); + } + if (empty($attributes['src'])) { + return $npf; + } + + $block = [ + 'type' => 'image', + 'media' => [], + ]; + + if (!empty($attributes['alt'])) { + $block['alt_text'] = $attributes['alt']; + } + + if (!empty($attributes['title']) && (($attributes['alt'] ?? '') != $attributes['title'])) { + $block['caption'] = $attributes['title']; + } + + $rid = Photo::ridFromURI($attributes['src']); + if (!empty($rid)) { + $photos = Photo::selectToArray([], ['resource-id' => $rid]); + foreach ($photos as $photo) { + $block['media'][] = [ + 'type' => $photo['type'], + 'url' => str_replace('-0.', '-' . $photo['scale'] . '.', $attributes['src']), + 'width' => $photo['width'], + 'height' => $photo['height'], + ]; + } + if (empty($attributes['alt']) && !empty($photos[0]['desc'])) { + $block['alt_text'] = $photos[0]['desc']; + } + } elseif ($media = Post\Media::getByURL($uri_id, $attributes['src'], [Post\Media::IMAGE])) { + $block['media'][] = [ + 'type' => $media['mimetype'], + 'url' => $media['url'], + 'width' => $media['width'], + 'height' => $media['height'], + ]; + if (empty($attributes['alt']) && !empty($media['description'])) { + $block['alt_text'] = $media['description']; + } + } else { + $block['media'][] = ['url' => $attributes['src']]; + } + + if ($level > 0) { + $block['indent_level'] = $level; + } + + $npf[] = $block; + + return $npf; + } + + /** + * Add an audio or video block + * + * @param DOMElement $element + * @param integer $uri_id + * @param integer $level + * @param array $npf + * @return array + */ + private static function addMediaBlock(DOMElement $element, int $uri_id, int $level, array $npf): array + { + $attributes = []; + foreach ($element->attributes as $key => $attribute) { + $attributes[$key] = trim($attribute->value); + } + if (empty($attributes['src'])) { + return $npf; + } + + $media = Post\Media::getByURL($uri_id, $attributes['src'], [Post\Media::AUDIO, Post\Media::VIDEO]); + if (!empty($media)) { + switch ($media['type']) { + case Post\Media::AUDIO: + $block = [ + 'type' => 'audio', + 'media' => [ + 'type' => $media['mimetype'], + 'url' => $media['url'], + ] + ]; + + if (!empty($media['name'])) { + $block['title'] = $media['name']; + } elseif (!empty($media['description'])) { + $block['title'] = $media['description']; + } + + $block = self::addPoster($media, $block); + break; + + case Post\Media::VIDEO: + $block = [ + 'type' => 'video', + 'media' => [ + 'type' => $media['mimetype'], + 'url' => $media['url'], + ] + ]; + + $block = self::addPoster($media, $block); + break; + } + } else { + $block = [ + 'type' => 'text', + 'text' => $element->textContent, + 'formatting' => [ + [ + 'start' => 0, + 'end' => mb_strlen($element->textContent), + 'type' => 'link', + 'url' => $attributes['src'] + ] + ] + ]; + } + + if ($level > 0) { + $block['indent_level'] = $level; + } + + $npf[] = $block; + + return $npf; + } +} diff --git a/src/Model/Post/Media.php b/src/Model/Post/Media.php index 012ec6b62..e80cc131f 100644 --- a/src/Model/Post/Media.php +++ b/src/Model/Post/Media.php @@ -757,6 +757,17 @@ class Media return DBA::selectToArray('post-media', [], $condition, ['order' => ['id']]); } + public static function getByURL(int $uri_id, string $url, array $types = []) + { + $condition = ["`uri-id` = ? AND `url` = ? AND `type` != ?", $uri_id, $url, self::UNKNOWN]; + + if (!empty($types)) { + $condition = DBA::mergeConditions($condition, ['type' => $types]); + } + + return DBA::selectFirst('post-media', [], $condition); + } + /** * Retrieves the media attachment with the provided media id. *