]> git.mxchange.org Git - friendica.git/blob - src/Model/Term.php
Merge pull request #8333 from MrPetovan/bug/8280-about-conversion-export
[friendica.git] / src / Model / Term.php
1 <?php
2 /**
3  * @copyright Copyright (C) 2020, Friendica
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\Model;
23
24 use Friendica\Core\Cache\Duration;
25 use Friendica\Core\Logger;
26 use Friendica\Database\DBA;
27 use Friendica\DI;
28 use Friendica\Util\Strings;
29
30 /**
31  * Class Term
32  *
33  * This Model class handles term table interactions.
34  * This tables stores relevant terms related to posts, photos and searches, like hashtags, mentions and
35  * user-applied categories.
36  */
37 class Term
38 {
39     const UNKNOWN           = 0;
40     const HASHTAG           = 1;
41     const MENTION           = 2;
42     const CATEGORY          = 3;
43     const PCATEGORY         = 4;
44     const FILE              = 5;
45     const SAVEDSEARCH       = 6;
46     const CONVERSATION      = 7;
47         /**
48          * An implicit mention is a mention in a comment body that is redundant with the threading information.
49          */
50     const IMPLICIT_MENTION  = 8;
51         /**
52          * An exclusive mention transfers the ownership of the post to the target account, usually a forum.
53          */
54     const EXCLUSIVE_MENTION = 9;
55
56     const TAG_CHARACTER = [
57         self::HASHTAG           => '#',
58         self::MENTION           => '@',
59         self::IMPLICIT_MENTION  => '%',
60         self::EXCLUSIVE_MENTION => '!',
61     ];
62
63     const OBJECT_TYPE_POST  = 1;
64     const OBJECT_TYPE_PHOTO = 2;
65
66         /**
67          * Returns a list of the most frequent global hashtags over the given period
68          *
69          * @param int $period Period in hours to consider posts
70          * @return array
71          * @throws \Exception
72          */
73         public static function getGlobalTrendingHashtags(int $period, $limit = 10)
74         {
75                 $tags = DI::cache()->get('global_trending_tags');
76
77                 if (!$tags) {
78                         $tagsStmt = DBA::p("SELECT t.`term`, COUNT(*) AS `score`
79                                 FROM `term` t
80                                  JOIN `item` i ON i.`id` = t.`oid` AND i.`uid` = t.`uid`
81                                  JOIN `thread` ON `thread`.`iid` = i.`id`
82                                 WHERE `thread`.`visible`
83                                   AND NOT `thread`.`deleted`
84                                   AND NOT `thread`.`moderated`
85                                   AND NOT `thread`.`private`
86                                   AND t.`uid` = 0
87                                   AND t.`otype` = ?
88                                   AND t.`type` = ?
89                                   AND t.`term` != ''
90                                   AND i.`received` > DATE_SUB(NOW(), INTERVAL ? HOUR)
91                                 GROUP BY `term`
92                                 ORDER BY `score` DESC
93                                 LIMIT ?",
94                                 Term::OBJECT_TYPE_POST,
95                                 Term::HASHTAG,
96                                 $period,
97                                 $limit
98                         );
99
100                         if (DBA::isResult($tagsStmt)) {
101                                 $tags = DBA::toArray($tagsStmt);
102                                 DI::cache()->set('global_trending_tags', $tags, Duration::HOUR);
103                         }
104                 }
105
106                 return $tags ?: [];
107         }
108
109         /**
110          * Returns a list of the most frequent local hashtags over the given period
111          *
112          * @param int $period Period in hours to consider posts
113          * @return array
114          * @throws \Exception
115          */
116         public static function getLocalTrendingHashtags(int $period, $limit = 10)
117         {
118                 $tags = DI::cache()->get('local_trending_tags');
119
120                 if (!$tags) {
121                         $tagsStmt = DBA::p("SELECT t.`term`, COUNT(*) AS `score`
122                                 FROM `term` t
123                                 JOIN `item` i ON i.`id` = t.`oid` AND i.`uid` = t.`uid`
124                                 JOIN `thread` ON `thread`.`iid` = i.`id`
125                                 JOIN `user` ON `user`.`uid` = `thread`.`uid` AND NOT `user`.`hidewall`
126                                 WHERE `thread`.`visible`
127                                   AND NOT `thread`.`deleted`
128                                   AND NOT `thread`.`moderated`
129                                   AND NOT `thread`.`private`
130                                   AND `thread`.`wall`
131                                   AND `thread`.`origin`
132                                   AND t.`otype` = ?
133                                   AND t.`type` = ?
134                                   AND t.`term` != ''
135                                   AND i.`received` > DATE_SUB(NOW(), INTERVAL ? HOUR)
136                                 GROUP BY `term`
137                                 ORDER BY `score` DESC
138                                 LIMIT ?",
139                                 Term::OBJECT_TYPE_POST,
140                                 Term::HASHTAG,
141                                 $period,
142                                 $limit
143                         );
144
145                         if (DBA::isResult($tagsStmt)) {
146                                 $tags = DBA::toArray($tagsStmt);
147                                 DI::cache()->set('local_trending_tags', $tags, Duration::HOUR);
148                         }
149                 }
150
151                 return $tags ?: [];
152         }
153
154         /**
155          * Generates the legacy item.tag field comma-separated BBCode string from an item ID.
156          * Includes only hashtags, implicit and explicit mentions.
157          *
158          * @param int $item_id
159          * @return string
160          * @throws \Exception
161          */
162         public static function tagTextFromItemId($item_id)
163         {
164                 $tag_list = [];
165                 $tags = self::tagArrayFromItemId($item_id, [self::HASHTAG, self::MENTION, self::IMPLICIT_MENTION]);
166                 foreach ($tags as $tag) {
167                         $tag_list[] = self::TAG_CHARACTER[$tag['type']] . '[url=' . $tag['url'] . ']' . $tag['term'] . '[/url]';
168                 }
169
170                 return implode(',', $tag_list);
171         }
172
173         /**
174          * Retrieves the terms from the provided type(s) associated with the provided item ID.
175          *
176          * @param int       $item_id
177          * @param int|array $type
178          * @return array
179          * @throws \Exception
180          */
181         public static function tagArrayFromItemId($item_id, $type = [self::HASHTAG, self::MENTION])
182         {
183                 $condition = ['otype' => self::OBJECT_TYPE_POST, 'oid' => $item_id, 'type' => $type];
184                 $tags = DBA::select('term', ['type', 'term', 'url'], $condition);
185                 if (!DBA::isResult($tags)) {
186                         return [];
187                 }
188
189                 return DBA::toArray($tags);
190         }
191
192         /**
193          * Generates the legacy item.file field string from an item ID.
194          * Includes only file and category terms.
195          *
196          * @param int $item_id
197          * @return string
198          * @throws \Exception
199          */
200         public static function fileTextFromItemId($item_id)
201         {
202                 $file_text = '';
203                 $tags = self::tagArrayFromItemId($item_id, [self::FILE, self::CATEGORY]);
204                 foreach ($tags as $tag) {
205                         if ($tag['type'] == self::CATEGORY) {
206                                 $file_text .= '<' . $tag['term'] . '>';
207                         } else {
208                                 $file_text .= '[' . $tag['term'] . ']';
209                         }
210                 }
211
212                 return $file_text;
213         }
214
215         /**
216          * Inserts new terms for the provided item ID based on the legacy item.tag field BBCode content.
217          * Deletes all previous tag terms for the same item ID.
218          * Sets both the item.mention and thread.mentions field flags if a mention concerning the item UID is found.
219          *
220          * @param int    $item_id
221          * @param string $tag_str
222          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
223          */
224         public static function insertFromTagFieldByItemId($item_id, $tag_str)
225         {
226                 $profile_base = DI::baseUrl();
227                 $profile_data = parse_url($profile_base);
228                 $profile_path = $profile_data['path'] ?? '';
229                 $profile_base_friendica = $profile_data['host'] . $profile_path . '/profile/';
230                 $profile_base_diaspora = $profile_data['host'] . $profile_path . '/u/';
231
232                 $fields = ['guid', 'uid', 'id', 'edited', 'deleted', 'created', 'received', 'title', 'body', 'parent'];
233                 $item = Item::selectFirst($fields, ['id' => $item_id]);
234                 if (!DBA::isResult($item)) {
235                         return;
236                 }
237
238                 $item['tag'] = $tag_str;
239
240                 // Clean up all tags
241                 self::deleteByItemId($item_id);
242
243                 if ($item['deleted']) {
244                         return;
245                 }
246
247                 $taglist = explode(',', $item['tag']);
248
249                 $tags_string = '';
250                 foreach ($taglist as $tag) {
251                         if (Strings::startsWith($tag, self::TAG_CHARACTER)) {
252                                 $tags_string .= ' ' . trim($tag);
253                         } else {
254                                 $tags_string .= ' #' . trim($tag);
255                         }
256                 }
257
258                 $data = ' ' . $item['title'] . ' ' . $item['body'] . ' ' . $tags_string . ' ';
259
260                 // ignore anything in a code block
261                 $data = preg_replace('/\[code\](.*?)\[\/code\]/sm', '', $data);
262
263                 $tags = [];
264
265                 $pattern = '/\W\#([^\[].*?)[\s\'".,:;\?!\[\]\/]/ism';
266                 if (preg_match_all($pattern, $data, $matches)) {
267                         foreach ($matches[1] as $match) {
268                                 $tags['#' . $match] = '';
269                         }
270                 }
271
272                 $pattern = '/\W([\#@!%])\[url\=(.*?)\](.*?)\[\/url\]/ism';
273                 if (preg_match_all($pattern, $data, $matches, PREG_SET_ORDER)) {
274                         foreach ($matches as $match) {
275
276                                 if (in_array($match[1], [
277                                         self::TAG_CHARACTER[self::MENTION],
278                                         self::TAG_CHARACTER[self::IMPLICIT_MENTION],
279                                         self::TAG_CHARACTER[self::EXCLUSIVE_MENTION]
280                                 ])) {
281                                         $contact = Contact::getDetailsByURL($match[2], 0);
282                                         if (!empty($contact['addr'])) {
283                                                 $match[3] = $contact['addr'];
284                                         }
285
286                                         if (!empty($contact['url'])) {
287                                                 $match[2] = $contact['url'];
288                                         }
289                                 }
290
291                                 $tags[$match[2]] = $match[1] . trim($match[3], ',.:;[]/\"?!');
292                         }
293                 }
294
295                 foreach ($tags as $link => $tag) {
296                         if (self::isType($tag, self::HASHTAG)) {
297                                 // try to ignore #039 or #1 or anything like that
298                                 if (ctype_digit(substr(trim($tag), 1))) {
299                                         continue;
300                                 }
301
302                                 // try to ignore html hex escapes, e.g. #x2317
303                                 if ((substr(trim($tag), 1, 1) == 'x' || substr(trim($tag), 1, 1) == 'X') && ctype_digit(substr(trim($tag), 2))) {
304                                         continue;
305                                 }
306
307                                 $type = self::HASHTAG;
308                                 $term = substr($tag, 1);
309                                 $link = '';
310                         } elseif (self::isType($tag, self::MENTION, self::EXCLUSIVE_MENTION, self::IMPLICIT_MENTION)) {
311                                 if (self::isType($tag, self::MENTION, self::EXCLUSIVE_MENTION)) {
312                                         $type = self::MENTION;
313                                 } else {
314                                         $type = self::IMPLICIT_MENTION;
315                                 }
316
317                                 $contact = Contact::getDetailsByURL($link, 0);
318                                 if (!empty($contact['name'])) {
319                                         $term = $contact['name'];
320                                 } else {
321                                         $term = substr($tag, 1);
322                                 }
323                         } else { // This shouldn't happen
324                                 $type = self::HASHTAG;
325                                 $term = $tag;
326                                 $link = '';
327
328                                 Logger::notice('Unknown term type', ['tag' => $tag]);
329                         }
330
331                         if (DBA::exists('term', ['uid' => $item['uid'], 'otype' => self::OBJECT_TYPE_POST, 'oid' => $item_id, 'term' => $term, 'type' => $type])) {
332                                 continue;
333                         }
334
335                         if ($item['uid'] == 0) {
336                                 $global = true;
337                                 DBA::update('term', ['global' => true], ['otype' => self::OBJECT_TYPE_POST, 'guid' => $item['guid']]);
338                         } else {
339                                 $global = DBA::exists('term', ['uid' => 0, 'otype' => self::OBJECT_TYPE_POST, 'guid' => $item['guid']]);
340                         }
341
342                         DBA::insert('term', [
343                                 'uid'      => $item['uid'],
344                                 'oid'      => $item_id,
345                                 'otype'    => self::OBJECT_TYPE_POST,
346                                 'type'     => $type,
347                                 'term'     => $term,
348                                 'url'      => $link,
349                                 'guid'     => $item['guid'],
350                                 'created'  => $item['created'],
351                                 'received' => $item['received'],
352                                 'global'   => $global
353                         ]);
354
355                         // Search for mentions
356                         if (self::isType($tag, self::MENTION, self::EXCLUSIVE_MENTION)
357                                 && (
358                                         strpos($link, $profile_base_friendica) !== false
359                                         || strpos($link, $profile_base_diaspora) !== false
360                                 )
361                         ) {
362                                 $users_stmt = DBA::p("SELECT `uid` FROM `contact` WHERE self AND (`url` = ? OR `nurl` = ?)", $link, $link);
363                                 $users = DBA::toArray($users_stmt);
364                                 foreach ($users AS $user) {
365                                         if ($user['uid'] == $item['uid']) {
366                                                 /// @todo This function is called from Item::update - so we mustn't call that function here
367                                                 DBA::update('item', ['mention' => true], ['id' => $item_id]);
368                                                 DBA::update('thread', ['mention' => true], ['iid' => $item['parent']]);
369                                         }
370                                 }
371                         }
372                 }
373         }
374
375         /**
376          * Inserts new terms for the provided item ID based on the legacy item.file field BBCode content.
377          * Deletes all previous file terms for the same item ID.
378          *
379          * @param integer $item_id item id
380          * @param         $files
381          * @return void
382          * @throws \Exception
383          */
384         public static function insertFromFileFieldByItemId($item_id, $files)
385         {
386                 $message = Item::selectFirst(['uid', 'deleted'], ['id' => $item_id]);
387                 if (!DBA::isResult($message)) {
388                         return;
389                 }
390
391                 // Clean up all tags
392                 DBA::delete('term', ['otype' => self::OBJECT_TYPE_POST, 'oid' => $item_id, 'type' => [self::FILE, self::CATEGORY]]);
393
394                 if ($message["deleted"]) {
395                         return;
396                 }
397
398                 $message['file'] = $files;
399
400                 if (preg_match_all("/\[(.*?)\]/ism", $message["file"], $files)) {
401                         foreach ($files[1] as $file) {
402                                 DBA::insert('term', [
403                                         'uid' => $message["uid"],
404                                         'oid' => $item_id,
405                                         'otype' => self::OBJECT_TYPE_POST,
406                                         'type' => self::FILE,
407                                         'term' => $file
408                                 ]);
409                         }
410                 }
411
412                 if (preg_match_all("/\<(.*?)\>/ism", $message["file"], $files)) {
413                         foreach ($files[1] as $file) {
414                                 DBA::insert('term', [
415                                         'uid' => $message["uid"],
416                                         'oid' => $item_id,
417                                         'otype' => self::OBJECT_TYPE_POST,
418                                         'type' => self::CATEGORY,
419                                         'term' => $file
420                                 ]);
421                         }
422                 }
423         }
424
425         /**
426          * Sorts an item's tags into mentions, hashtags and other tags. Generate personalized URLs by user and modify the
427          * provided item's body with them.
428          *
429          * @param array $item
430          * @return array
431          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
432          * @throws \ImagickException
433          */
434         public static function populateTagsFromItem(&$item)
435         {
436                 $return = [
437                         'tags' => [],
438                         'hashtags' => [],
439                         'mentions' => [],
440                         'implicit_mentions' => [],
441                 ];
442
443                 $searchpath = DI::baseUrl() . "/search?tag=";
444
445                 $taglist = DBA::select(
446                         'term',
447                         ['type', 'term', 'url'],
448                         ['otype' => self::OBJECT_TYPE_POST, 'oid' => $item['id'], 'type' => [self::HASHTAG, self::MENTION, self::IMPLICIT_MENTION]],
449                         ['order' => ['tid']]
450                 );
451                 while ($tag = DBA::fetch($taglist)) {
452                         if ($tag['url'] == '') {
453                                 $tag['url'] = $searchpath . rawurlencode($tag['term']);
454                         }
455
456                         $orig_tag = $tag['url'];
457
458                         $prefix = self::TAG_CHARACTER[$tag['type']];
459                         switch($tag['type']) {
460                                 case self::HASHTAG:
461                                         if ($orig_tag != $tag['url']) {
462                                                 $item['body'] = str_replace($orig_tag, $tag['url'], $item['body']);
463                                         }
464
465                                         $return['hashtags'][] = $prefix . '<a href="' . $tag['url'] . '" target="_blank" rel="noopener noreferrer">' . htmlspecialchars($tag['term']) . '</a>';
466                                         $return['tags'][] = $prefix . '<a href="' . $tag['url'] . '" target="_blank" rel="noopener noreferrer">' . htmlspecialchars($tag['term']) . '</a>';
467                                         break;
468                                 case self::MENTION:
469                                         $tag['url'] = Contact::magicLink($tag['url']);
470                                         $return['mentions'][] = $prefix . '<a href="' . $tag['url'] . '" target="_blank" rel="noopener noreferrer">' . htmlspecialchars($tag['term']) . '</a>';
471                                         $return['tags'][] = $prefix . '<a href="' . $tag['url'] . '" target="_blank" rel="noopener noreferrer">' . htmlspecialchars($tag['term']) . '</a>';
472                                         break;
473                                 case self::IMPLICIT_MENTION:
474                                         $return['implicit_mentions'][] = $prefix . $tag['term'];
475                                         break;
476                         }
477                 }
478                 DBA::close($taglist);
479
480                 return $return;
481         }
482
483         /**
484          * Delete tags of the specific type(s) from an item
485          *
486          * @param int       $item_id
487          * @param int|array $type
488          * @throws \Exception
489          */
490         public static function deleteByItemId($item_id, $type = [self::HASHTAG, self::MENTION, self::IMPLICIT_MENTION])
491         {
492                 if (empty($item_id)) {
493                         return;
494                 }
495
496                 // Clean up all tags
497                 DBA::delete('term', ['otype' => self::OBJECT_TYPE_POST, 'oid' => $item_id, 'type' => $type]);
498         }
499
500         /**
501          * Check if the provided tag is of one of the provided term types.
502          *
503          * @param string $tag
504          * @param int    ...$types
505          * @return bool
506          */
507         public static function isType($tag, ...$types)
508         {
509                 $tag_chars = [];
510                 foreach ($types as $type) {
511                         if (array_key_exists($type, self::TAG_CHARACTER)) {
512                                 $tag_chars[] = self::TAG_CHARACTER[$type];
513                         }
514                 }
515
516                 return Strings::startsWith($tag, $tag_chars);
517         }
518 }