]> git.mxchange.org Git - friendica.git/blob - src/Content/Text/NPF.php
Posts per author/server on the community pages (#13764)
[friendica.git] / src / Content / Text / NPF.php
1 <?php
2 /**
3  * @copyright Copyright (C) 2010-2023, the Friendica project
4  *
5  * @license GNU AGPL version 3 or any later version
6  *
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.
11  *
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.
16  *
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/>.
19  *
20  */
21
22 namespace Friendica\Content\Text;
23
24 use DOMDocument;
25 use DOMElement;
26 use Friendica\Model\Photo;
27 use Friendica\Model\Post;
28
29 /**
30  * Tumblr Neue Post Format
31  * @see https://www.tumblr.com/docs/npf
32  */
33 class NPF
34 {
35         private static $heading_subtype = [];
36
37         /**
38          * Convert BBCode into NPF (Tumblr Neue Post Format)
39          *
40          * @param string $bbcode
41          * @param integer $uri_id
42          * @return array NPF
43          */
44         public static function fromBBCode(string $bbcode, int $uri_id): array
45         {
46                 $bbcode = self::prepareBody($bbcode);
47
48                 $html = BBCode::convertForUriId($uri_id, $bbcode, BBCode::NPF);
49                 if (empty($html)) {
50                         return [];
51                 }
52
53                 $doc = new DOMDocument();
54
55                 $doc->formatOutput = true;
56                 if (!@$doc->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'))) {
57                         return [];
58                 }
59
60                 self::setHeadingSubStyles($doc);
61
62                 $element = $doc->getElementsByTagName('body')->item(0);
63
64                 list($npf, $text, $formatting) = self::routeChildren($element, $uri_id, true, []);
65
66                 return self::addLinkBlockForUriId($uri_id, 0, $npf);
67         }
68
69         /**
70          * Fetch the heading types
71          *
72          * @param DOMDocument $doc
73          * @return void
74          */
75         private static function setHeadingSubStyles(DOMDocument $doc)
76         {
77                 self::$heading_subtype = [];
78                 foreach (['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] as $element) {
79                         if ($doc->getElementsByTagName($element)->count() > 0) {
80                                 if (empty(self::$heading_subtype)) {
81                                         self::$heading_subtype[$element] = 'heading1';
82                                 } else {
83                                         self::$heading_subtype[$element] = 'heading2';
84                                 }
85                         }
86                 }
87         }
88
89         /**
90          * Prepare the BBCode for the NPF conversion
91          *
92          * @param string $bbcode
93          * @return string
94          */
95         private static function prepareBody(string $bbcode): string
96         {
97                 $shared = BBCode::fetchShareAttributes($bbcode);
98                 if (!empty($shared)) {
99                         $bbcode = $shared['shared'];
100                 }
101
102                 $bbcode = preg_replace("/\[img\=([0-9]*)x([0-9]*)\](.*?)\[\/img\]/ism", '[img]$3[/img]', $bbcode);
103
104                 if (preg_match_all("#\[url=([^\]]+?)\]\s*\[img=([^\[\]]*)\]([^\[\]]*)\[\/img\]\s*\[/url\]#ism", $bbcode, $pictures, PREG_SET_ORDER)) {
105                         foreach ($pictures as $picture) {
106                                 if (preg_match('#/photo/.*-[01]\.#ism', $picture[2]) && (preg_match('#/photo/.*-0\.#ism', $picture[1]) || preg_match('#/photos/.*/image/#ism', $picture[1]))) {
107                                         $bbcode = str_replace($picture[0], "\n\n[img=" . str_replace('-1.', '-0.', $picture[2]) . "]" . $picture[3] . "[/img]\n\n", $bbcode);
108                                 }
109                         }
110                 }
111
112                 $bbcode = preg_replace("/\[img\=(.*?)\](.*?)\[\/img\]/ism", "\n\n[img=$1]$2[/img]\n\n", $bbcode);
113
114                 if (preg_match_all("#\[url=([^\]]+?)\]\s*\[img\]([^\[]+?)\[/img\]\s*\[/url\]#ism", $bbcode, $pictures, PREG_SET_ORDER)) {
115                         foreach ($pictures as $picture) {
116                                 if (preg_match('#/photo/.*-[01]\.#ism', $picture[2]) && (preg_match('#/photo/.*-0\.#ism', $picture[1]) || preg_match('#/photos/.*/image/#ism', $picture[1]))) {
117                                         $bbcode = str_replace($picture[0], "\n\n[img]" . str_replace('-1.', '-0.', $picture[2]) . "[/img]\n\n", $bbcode);
118                                 }
119                         }
120                 }
121
122                 $bbcode = preg_replace("/\[img\](.*?)\[\/img\]/ism", "\n\n[img]$1[/img]\n\n", $bbcode);
123
124                 do {
125                         $oldbbcode = $bbcode;
126                         $bbcode    = str_replace(["\n\n\n"], ["\n\n"], $bbcode);
127                 } while ($oldbbcode != $bbcode);
128
129                 return trim($bbcode);
130         }
131
132         /**
133          * Walk recursively through the HTML
134          *
135          * @param DOMElement $element
136          * @param integer $uri_id
137          * @param boolean $parse_structure
138          * @param array $callstack
139          * @param array $npf
140          * @param string $text
141          * @param array $formatting
142          * @return array
143          */
144         private static function routeChildren(DOMElement $element, int $uri_id, bool $parse_structure, array $callstack, array $npf = [], string $text = '', array $formatting = []): array
145         {
146                 if ($parse_structure && $text) {
147                         list($npf, $text, $formatting) = self::addBlock($text, $formatting, $npf, $callstack);
148                 }
149
150                 $callstack[] = $element->nodeName;
151                 $level       = self::getLevelByCallstack($callstack);
152
153                 foreach ($element->childNodes as $child) {
154                         switch ($child->nodeName) {
155                                 case 'b':
156                                 case 'strong':
157                                         list($npf, $text, $formatting) = self::addFormatting($child, $uri_id, 'bold', $callstack, $npf, $text, $formatting);
158                                         break;
159
160                                 case 'i':
161                                 case 'em':
162                                         list($npf, $text, $formatting) = self::addFormatting($child, $uri_id, 'italic', $callstack, $npf, $text, $formatting);
163                                         break;
164
165                                 case 's':
166                                         list($npf, $text, $formatting) = self::addFormatting($child, $uri_id, 'strikethrough', $callstack, $npf, $text, $formatting);
167                                         break;
168
169                                 case 'u':
170                                 case 'span':
171                                         list($npf, $text, $formatting) = self::addFormatting($child, $uri_id, '', $callstack, $npf, $text, $formatting);
172                                         break;
173
174                                 case 'hr':
175                                 case 'br':
176                                         if (!empty($text)) {
177                                                 $text .= "\n";
178                                         }
179                                         break;
180
181                                 case '#text':
182                                         $text .= $child->textContent;
183                                         break;
184
185                                 case 'table':
186                                 case 'summary':
187                                         // Ignore tables and spoilers
188                                         break;
189
190                                 case 'a':
191                                         list($npf, $text, $formatting) = self::addInlineLink($child, $uri_id, $callstack, $npf, $text, $formatting);
192                                         break;
193
194                                 case 'img':
195                                         $npf = self::addImageBlock($child, $uri_id, $level, $npf);
196                                         break;
197
198                                 case 'audio':
199                                 case 'video':
200                                         $npf = self::addMediaBlock($child, $uri_id, $level, $npf);
201                                         break;
202
203                                 default:
204                                         list($npf, $text, $formatting) = self::routeChildren($child, $uri_id, true, $callstack, $npf, $text, $formatting);
205                                         break;
206                         }
207                 }
208
209                 if ($parse_structure && $text) {
210                         list($npf, $text, $formatting) = self::addBlock($text, $formatting, $npf, $callstack);
211                 }
212                 return [$npf, $text, $formatting];
213         }
214
215         /**
216          * Return the correct indent level
217          *
218          * @param array $callstack
219          * @return integer
220          */
221         private static function getLevelByCallstack(array $callstack): int
222         {
223                 $level = 0;
224                 foreach ($callstack as $entry) {
225                         if (in_array($entry, ['ol', 'ul', 'blockquote'])) {
226                                 ++$level;
227                         }
228                 }
229                 return max(0, $level - 1);
230         }
231
232         /**
233          * Detect the subtype via the HTML element callstack
234          *
235          * @param array $callstack
236          * @param string $text
237          * @return string
238          */
239         private static function getSubTypeByCallstack(array $callstack, string $text): string
240         {
241                 $subtype = '';
242                 foreach ($callstack as $entry) {
243                         switch ($entry) {
244                                 case 'ol':
245                                         $subtype = 'ordered-list-item';
246                                         break;
247
248                                 case 'ul':
249                                         $subtype = 'unordered-list-item';
250                                         break;
251
252                                 case 'h1':
253                                         $subtype = self::$heading_subtype[$entry];
254                                         break;
255
256                                 case 'h2':
257                                         $subtype = self::$heading_subtype[$entry];
258                                         break;
259
260                                 case 'h3':
261                                         $subtype = self::$heading_subtype[$entry];
262                                         break;
263
264                                 case 'h4':
265                                         $subtype = self::$heading_subtype[$entry];
266                                         break;
267
268                                 case 'h5':
269                                         $subtype = self::$heading_subtype[$entry];
270                                         break;
271
272                                 case 'h6':
273                                         $subtype = self::$heading_subtype[$entry];
274                                         break;
275
276                                 case 'blockquote':
277                                         $subtype = mb_strlen($text) < 100 ? 'quote' : 'indented';
278                                         break;
279
280                                 case 'pre':
281                                         $subtype = 'indented';
282                                         break;
283
284                                 case 'code':
285                                         $subtype = 'chat';
286                                         break;
287                         }
288                 }
289                 return $subtype;
290         }
291
292         /**
293          * Add formatting for a text block
294          *
295          * @param DOMElement $element
296          * @param integer $uri_id
297          * @param string $type
298          * @param array $callstack
299          * @param array $npf
300          * @param string $text
301          * @param array $formatting
302          * @return array
303          */
304         private static function addFormatting(DOMElement $element, int $uri_id, string $type, array $callstack, array $npf, string $text, array $formatting): array
305         {
306                 $start = mb_strlen($text);
307
308                 list($npf, $text, $formatting) = self::routeChildren($element, $uri_id, false, $callstack, $npf, $text, $formatting);
309
310                 if (!empty($type)) {
311                         $formatting[] = [
312                                 'start' => $start,
313                                 'end'   => mb_strlen($text),
314                                 'type'  => $type
315                         ];
316                 }
317                 return [$npf, $text, $formatting];
318         }
319
320         /**
321          * Add an inline link for a text block
322          *
323          * @param DOMElement $element
324          * @param integer $uri_id
325          * @param array $callstack
326          * @param array $npf
327          * @param string $text
328          * @param array $formatting
329          * @return array
330          */
331         private static function addInlineLink(DOMElement $element, int $uri_id, array $callstack, array $npf, string $text, array $formatting): array
332         {
333                 $start = mb_strlen($text);
334
335                 list($npf, $text, $formatting) = self::routeChildren($element, $uri_id, false, $callstack, $npf, $text, $formatting);
336
337                 $attributes = [];
338                 foreach ($element->attributes as $key => $attribute) {
339                         $attributes[$key] = trim($attribute->value);
340                 }
341                 if (!empty($attributes['href'])) {
342                         $formatting[] = [
343                                 'start' => $start,
344                                 'end'   => mb_strlen($text),
345                                 'type'  => 'link',
346                                 'url'   => $attributes['href']
347                         ];
348                 }
349                 return [$npf, $text, $formatting];
350         }
351
352         /**
353          * Add a text block
354          *
355          * @param string $text
356          * @param array $formatting
357          * @param array $npf
358          * @param array $callstack
359          * @return array
360          */
361         private static function addBlock(string $text, array $formatting, array $npf, array $callstack): array
362         {
363                 $block = [
364                         'type'    => 'text',
365                         'subtype' => '',
366                         'text'    => $text,
367                 ];
368
369                 if (!empty($formatting)) {
370                         $block['formatting'] = $formatting;
371                 }
372
373                 $level = self::getLevelByCallstack($callstack);
374                 if ($level > 0) {
375                         $block['indent_level'] = $level;
376                 }
377
378                 $subtype = self::getSubTypeByCallstack($callstack, $text);
379                 if ($subtype) {
380                         $block['subtype'] = $subtype;
381                 } else {
382                         unset($block['subtype']);
383                 }
384
385                 $npf[] = $block;
386                 return [$npf, '', []];
387         }
388
389         /**
390          * Add a block for a preview picture
391          *
392          * @param array $media
393          * @param array $block
394          * @return array
395          */
396         private static function addPoster(array $media, array $block): array
397         {
398                 $poster = [];
399                 if (!empty($media['preview'])) {
400                         $poster['url'] = $media['preview'];
401                 }
402                 if (!empty($media['preview-width'])) {
403                         $poster['width'] = $media['preview-width'];
404                 }
405                 if (!empty($media['preview-height'])) {
406                         $poster['height'] = $media['preview-height'];
407                 }
408                 if (!empty($poster)) {
409                         $block['poster'] = [$poster];
410                 }
411                 return $block;
412         }
413
414         /**
415          * Add a link block from the HTML attachment of a given post uri-id
416          *
417          * @param integer $uri_id
418          * @param integer $level
419          * @param array $npf
420          * @return array
421          */
422         private static function addLinkBlockForUriId(int $uri_id, int $level, array $npf): array
423         {
424                 foreach (Post\Media::getByURIId($uri_id, [Post\Media::HTML]) as $link) {
425                         $host = parse_url($link['url'], PHP_URL_HOST);
426                         if (in_array($host, ['www.youtube.com', 'youtu.be'])) {
427                                 $block = [
428                                         'type'     => 'video',
429                                         'provider' => 'youtube',
430                                         'url'      => $link['url'],
431                                 ];
432                         } elseif (in_array($host, ['vimeo.com'])) {
433                                 $block = [
434                                         'type'     => 'video',
435                                         'provider' => 'vimeo',
436                                         'url'      => $link['url'],
437                                 ];
438                         } elseif (in_array($host, ['open.spotify.com'])) {
439                                 $block = [
440                                         'type'     => 'audio',
441                                         'provider' => 'spotify',
442                                         'url'      => $link['url'],
443                                 ];
444                         } else {
445                                 $block = [
446                                         'type' => 'link',
447                                         'url'  => $link['url'],
448                                 ];
449                                 if (!empty($link['name'])) {
450                                         $block['title'] = $link['name'];
451                                 }
452                                 if (!empty($link['description'])) {
453                                         $block['description'] = $link['description'];
454                                 }
455                                 if (!empty($link['author-name'])) {
456                                         $block['author'] = $link['author-name'];
457                                 }
458                                 if (!empty($link['publisher-name'])) {
459                                         $block['site_name'] = $link['publisher-name'];
460                                 }
461                         }
462
463                         if ($level > 0) {
464                                 $block['indent_level'] = $level;
465                         }
466
467                         $npf[] = self::addPoster($link, $block);
468                 }
469                 return $npf;
470         }
471
472         /**
473          * Add an image block
474          *
475          * @param DOMElement $element
476          * @param integer $uri_id
477          * @param integer $level
478          * @param array $npf
479          * @return array
480          */
481         private static function addImageBlock(DOMElement $element, int $uri_id, int $level, array $npf): array
482         {
483                 $attributes = [];
484                 foreach ($element->attributes as $key => $attribute) {
485                         $attributes[$key] = trim($attribute->value);
486                 }
487                 if (empty($attributes['src'])) {
488                         return $npf;
489                 }
490
491                 $block = [
492                         'type'  => 'image',
493                         'media' => [],
494                 ];
495
496                 if (!empty($attributes['alt'])) {
497                         $block['alt_text'] = $attributes['alt'];
498                 }
499
500                 if (!empty($attributes['title']) && (($attributes['alt'] ?? '') != $attributes['title'])) {
501                         $block['caption'] = $attributes['title'];
502                 }
503
504                 $rid = Photo::ridFromURI($attributes['src']);
505                 if (!empty($rid)) {
506                         $photos = Photo::selectToArray([], ['resource-id' => $rid]);
507                         foreach ($photos as $photo) {
508                                 $block['media'][] = [
509                                         'type'   => $photo['type'],
510                                         'url'    => str_replace('-0.', '-' . $photo['scale'] . '.', $attributes['src']),
511                                         'width'  => $photo['width'],
512                                         'height' => $photo['height'],
513                                 ];
514                         }
515                         if (empty($attributes['alt']) && !empty($photos[0]['desc'])) {
516                                 $block['alt_text'] = $photos[0]['desc'];
517                         }
518                 } elseif ($media = Post\Media::getByURL($uri_id, $attributes['src'], [Post\Media::IMAGE])) {
519                         $block['media'][] = [
520                                 'type'   => $media['mimetype'],
521                                 'url'    => $media['url'],
522                                 'width'  => $media['width'],
523                                 'height' => $media['height'],
524                         ];
525                         if (empty($attributes['alt']) && !empty($media['description'])) {
526                                 $block['alt_text'] = $media['description'];
527                         }
528                 } else {
529                         $block['media'][] = ['url' => $attributes['src']];
530                 }
531
532                 if ($level > 0) {
533                         $block['indent_level'] = $level;
534                 }
535
536                 $npf[] = $block;
537
538                 return $npf;
539         }
540
541         /**
542          * Add an audio or video block
543          *
544          * @param DOMElement $element
545          * @param integer $uri_id
546          * @param integer $level
547          * @param array $npf
548          * @return array
549          */
550         private static function addMediaBlock(DOMElement $element, int $uri_id, int $level, array $npf): array
551         {
552                 $attributes = [];
553                 foreach ($element->attributes as $key => $attribute) {
554                         $attributes[$key] = trim($attribute->value);
555                 }
556                 if (empty($attributes['src'])) {
557                         return $npf;
558                 }
559
560                 $media = Post\Media::getByURL($uri_id, $attributes['src'], [Post\Media::AUDIO, Post\Media::VIDEO]);
561                 if (!empty($media)) {
562                         switch ($media['type']) {
563                                 case Post\Media::AUDIO:
564                                         $block = [
565                                                 'type'  => 'audio',
566                                                 'media' => [
567                                                         'type' => $media['mimetype'],
568                                                         'url'  => $media['url'],
569                                                 ]
570                                         ];
571
572                                         if (!empty($media['name'])) {
573                                                 $block['title'] = $media['name'];
574                                         } elseif (!empty($media['description'])) {
575                                                 $block['title'] = $media['description'];
576                                         }
577
578                                         $block = self::addPoster($media, $block);
579                                         break;
580
581                                 case Post\Media::VIDEO:
582                                         $block = [
583                                                 'type'  => 'video',
584                                                 'media' => [
585                                                         'type' => $media['mimetype'],
586                                                         'url'  => $media['url'],
587                                                 ]
588                                         ];
589
590                                         $block = self::addPoster($media, $block);
591                                         break;
592                         }
593                 } else {
594                         $block = [
595                                 'type'       => 'text',
596                                 'text'       => $element->textContent,
597                                 'formatting' => [
598                                         [
599                                                 'start' => 0,
600                                                 'end'   => mb_strlen($element->textContent),
601                                                 'type'  => 'link',
602                                                 'url'   => $attributes['src']
603                                         ]
604                                 ]
605                         ];
606                 }
607
608                 if ($level > 0) {
609                         $block['indent_level'] = $level;
610                 }
611
612                 $npf[] = $block;
613
614                 return $npf;
615         }
616 }