3 * @copyright Copyright (C) 2010-2023, the Friendica project
5 * @license GNU AGPL version 3 or any later version
7 * This program is free software: you can redistribute it and/or modify
8 * it under the terms of the GNU Affero General Public License as
9 * published by the Free Software Foundation, either version 3 of the
10 * License, or (at your option) any later version.
12 * This program is distributed in the hope that it will be useful,
13 * but WITHOUT ANY WARRANTY; without even the implied warranty of
14 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 * GNU Affero General Public License for more details.
17 * You should have received a copy of the GNU Affero General Public License
18 * along with this program. If not, see <https://www.gnu.org/licenses/>.
22 namespace Friendica\Content\Text;
26 use Friendica\Model\Photo;
27 use Friendica\Model\Post;
30 * Tumblr Neue Post Format
31 * @see https://www.tumblr.com/docs/npf
35 static public function fromBBCode(string $bbcode, int $uri_id): array
39 $bbcode = self::prepareBody($bbcode);
41 $html = BBCode::convert($bbcode, false, BBCode::CONNECTORS);
46 $doc = new DOMDocument();
47 if (!@$doc->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'))) {
51 $node = $doc->getElementsByTagName('body')->item(0);
52 foreach ($node->childNodes as $child) {
53 if ($child->nodeName == '#text') {
56 'text' => $child->textContent,
59 $npf = self::routeElements($child, $uri_id, $npf);
63 return self::addLinkBlock($uri_id, $npf);
66 public static function prepareBody(string $body): string
68 $shared = BBCode::fetchShareAttributes($body);
69 if (!empty($shared)) {
70 $body = $shared['shared'];
73 $body = BBCode::removeAttachment($body);
75 $body = preg_replace("/\[img\=([0-9]*)x([0-9]*)\](.*?)\[\/img\]/ism", '[img]$3[/img]', $body);
77 if (preg_match_all("#\[url=([^\]]+?)\]\s*\[img=([^\[\]]*)\]([^\[\]]*)\[\/img\]\s*\[/url\]#ism", $body, $pictures, PREG_SET_ORDER)) {
78 foreach ($pictures as $picture) {
79 if (preg_match('#/photo/.*-[01]\.#ism', $picture[2]) && (preg_match('#/photo/.*-0\.#ism', $picture[1]) || preg_match('#/photos/.*/image/#ism', $picture[1]))) {
80 $body = str_replace($picture[0], "\n\n[img=" . str_replace('-1.', '-0.', $picture[2]) . "]" . $picture[3] . "[/img]\n\n", $body);
85 $body = preg_replace("/\[img\=(.*?)\](.*?)\[\/img\]/ism", "\n\n[img=$1]$2[/img]\n\n", $body);
87 if (preg_match_all("#\[url=([^\]]+?)\]\s*\[img\]([^\[]+?)\[/img\]\s*\[/url\]#ism", $body, $pictures, PREG_SET_ORDER)) {
88 foreach ($pictures as $picture) {
89 if (preg_match('#/photo/.*-[01]\.#ism', $picture[2]) && (preg_match('#/photo/.*-0\.#ism', $picture[1]) || preg_match('#/photos/.*/image/#ism', $picture[1]))) {
90 $body = str_replace($picture[0], "\n\n[img]" . str_replace('-1.', '-0.', $picture[2]) . "[/img]\n\n", $body);
95 $body = preg_replace("/\[img\](.*?)\[\/img\]/ism", "\n\n[img]$1[/img]\n\n", $body);
96 $body = preg_replace("/\[audio\](.*?)\[\/audio\]/ism", "\n\n[audio]$1[/audio]\n\n", $body);
97 $body = preg_replace("/\[video\](.*?)\[\/video\]/ism", "\n\n[video]$1[/video]\n\n", $body);
101 $body = str_replace(["\n\n\n"], ["\n\n"], $body);
102 } while ($oldbody != $body);
107 static private function routeElements(DOMElement $child, int $uri_id, array $npf): array
109 switch ($child->nodeName) {
111 $npf = self::addTextBlock($child, $uri_id, $npf, 'indented');
115 $npf = self::addTextBlock($child, $uri_id, $npf, 'heading1');
119 $npf = self::addTextBlock($child, $uri_id, $npf, 'heading1');
123 $npf = self::addTextBlock($child, $uri_id, $npf, 'heading1');
127 $npf = self::addTextBlock($child, $uri_id, $npf, 'heading2');
131 $npf = self::addTextBlock($child, $uri_id, $npf, 'heading2');
135 $npf = self::addTextBlock($child, $uri_id, $npf, 'heading2');
139 $npf = self::addListBlock($child, $uri_id, $npf, false, 0);
143 $npf = self::addListBlock($child, $uri_id, $npf, true, 0);
152 $npf = self::addTextBlock($child, $uri_id, $npf, 'indented');
156 $npf = self::addMediaBlock($child, $uri_id, $npf);
161 // $child->ownerDocument->saveHTML($child)
165 $npf = self::addImageBlock($child, $uri_id, $npf);
169 $npf = self::addTextBlock($child, $uri_id, $npf);
175 static private function addImageBlock(DOMElement $child, int $uri_id, array $npf): array
178 foreach ($child->attributes as $key => $attribute) {
179 $attributes[$key] = $attribute->value;
181 if (empty($attributes['src'])) {
190 if (!empty($attributes['alt'])) {
191 $entry['alt_text'] = $attributes['alt'];
194 if (!empty($attributes['title']) && ($attributes['alt'] ?? '' != $attributes['title'])) {
195 $entry['caption'] = $attributes['title'];
198 $rid = Photo::ridFromURI($attributes['src']);
200 $photos = Photo::selectToArray([], ['resource-id' => $rid]);
201 foreach ($photos as $photo) {
202 $entry['media'][] = [
203 'type' => $photo['type'],
204 'url' => str_replace('-0.', '-' . $photo['scale'] . '.', $attributes['src']),
205 'width' => $photo['width'],
206 'height' => $photo['height'],
209 if (empty($attributes['alt']) && !empty($photos[0]['desc'])) {
210 $entry['alt_text'] = $photos[0]['desc'];
212 } elseif ($media = Post\Media::getByURL($uri_id, $attributes['src'], [Post\Media::IMAGE])) {
213 $entry['media'][] = [
214 'type' => $media['mimetype'],
215 'url' => $media['url'],
216 'width' => $media['width'],
217 'height' => $media['height'],
219 if (empty($attributes['alt']) && !empty($media['description'])) {
220 $entry['alt_text'] = $media['description'];
223 $entry['media'][] = ['url' => $attributes['src']];
231 static private function addMediaBlock(DOMElement $child, int $uri_id, array $npf): array
234 foreach ($child->attributes as $key => $attribute) {
235 $attributes[$key] = $attribute->value;
237 if (empty($attributes['href'])) {
241 $media = Post\Media::getByURL($uri_id, $attributes['href'], [Post\Media::AUDIO, Post\Media::VIDEO]);
242 if (!empty($media)) {
243 switch ($media['type']) {
244 case Post\Media::AUDIO:
248 'type' => $media['mimetype'],
249 'url' => $media['url'],
253 if (!empty($media['name'])) {
254 $entry['title'] = $media['name'];
255 } elseif (!empty($media['description'])) {
256 $entry['title'] = $media['description'];
259 $npf[] = self::addPoster($media, $entry);
262 case Post\Media::VIDEO:
266 'type' => $media['mimetype'],
267 'url' => $media['url'],
271 $npf[] = self::addPoster($media, $entry);
277 'text' => $child->textContent,
280 'end' => strlen($child->textContent),
282 'url' => $attributes['href']
289 static private function addPoster(array $media, array $entry): array
292 if (!empty($media['preview'])) {
293 $poster['url'] = $media['preview'];
295 if (!empty($media['preview-width'])) {
296 $poster['width'] = $media['preview-width'];
298 if (!empty($media['preview-height'])) {
299 $poster['height'] = $media['preview-height'];
301 if (!empty($poster)) {
302 $entry['poster'] = $poster;
307 static private function getTypeForNodeName(string $nodename): string
319 return 'strikethrough';
324 static private function fetchText(DOMElement $child, array $text = ['text' => '', 'formatting' => []]): array
326 foreach ($child->childNodes as $node) {
327 $start = strlen($text['text']);
329 $type = self::getTypeForNodeName($node->nodeName);
331 if ($node->nodeName == 'br') {
332 $text['text'] .= "\n";
333 } elseif (($type != '') || in_array($node->nodeName, ['#text', 'code', 'a', 'p', 'span', 'u', 'img', 'summary', 'ul', 'blockquote', 'h3', 'ol'])) {
334 $text['text'] .= $node->textContent;
336 echo $child->ownerDocument->saveHTML($child) . "\n";
337 die($node->nodeName . "\n");
340 $text['formatting'][] = ['start' => $start, 'end' => strlen($text['text']), 'type' => $type];
346 static private function addTextBlock(DOMElement $child, int $uri_id, array $npf, string $subtype = ''): array
348 if (empty($subtype) && ($child->textContent == $child->firstChild->textContent) && ($child->firstChild->nodeName != '#text')) {
349 return self::routeElements($child->firstChild, $uri_id, $npf);
352 $element = ['type' => 'text'];
354 if (!empty($subtype)) {
355 $element['subtype'] = $subtype;
358 $text = self::fetchText($child);
360 $element['text'] = $text['text'];
361 $element['formatting'] = $text['formatting'];
363 if (empty($subtype)) {
364 $type = self::getTypeForNodeName($child->nodeName);
366 $element['formatting'][] = ['start' => 0, 'end' => strlen($element['text']), 'type' => $type];
370 if (empty($element['formatting'])) {
371 unset($element['formatting']);
379 static private function addListBlock(DOMElement $child, int $uri_id, array $npf, bool $ordered, int $level): array
381 foreach ($child->childNodes as $node) {
382 switch ($node->nodeName) {
384 $npf = self::addListBlock($node, $uri_id, $npf, false, $level++);
386 $npf = self::addListBlock($node, $uri_id, $npf, true, $level++);
388 $text = self::fetchText($node);
392 'subtype' => $ordered ? 'ordered-list-item' : 'unordered-list-item',
393 'text' => $text['text']
396 $entry['indent_level'] = $level;
398 if (!empty($text['formatting'])) {
399 $entry['formatting'] = $text['formatting'];
408 static private function addLinkBlock(int $uri_id, array $npf): array
410 foreach (Post\Media::getByURIId($uri_id, [Post\Media::HTML]) as $link) {
411 $host = parse_url($link['url'], PHP_URL_HOST);
412 if (in_array($host, ['www.youtube.com', 'youtu.be'])) {
415 'provider' => 'youtube',
416 'url' => $link['url'],
418 } elseif (in_array($host, ['vimeo.com'])) {
421 'provider' => 'vimeo',
422 'url' => $link['url'],
424 } elseif (in_array($host, ['open.spotify.com'])) {
427 'provider' => 'spotify',
428 'url' => $link['url'],
433 'url' => $link['url'],
435 if (!empty($link['name'])) {
436 $entry['title'] = $link['name'];
438 if (!empty($link['description'])) {
439 $entry['description'] = $link['description'];
441 if (!empty($link['author-name'])) {
442 $entry['author'] = $link['author-name'];
444 if (!empty($link['publisher-name'])) {
445 $entry['site_name'] = $link['publisher-name'];
449 $npf[] = self::addPoster($link, $entry);