]> git.mxchange.org Git - friendica.git/blob - src/Model/Tag.php
0a3e0e85337f6def2a7535e0fd301681390f406a
[friendica.git] / src / Model / Tag.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\Content\Text\BBCode;
25 use Friendica\Core\Cache\Duration;
26 use Friendica\Core\Logger;
27 use Friendica\Core\System;
28 use Friendica\Database\DBA;
29 use Friendica\DI;
30 use Friendica\Util\Strings;
31
32 /**
33  * Class Tag
34  *
35  * This Model class handles tag table interactions.
36  * This tables stores relevant tags related to posts, like hashtags and mentions.
37  */
38 class Tag
39 {
40         const UNKNOWN  = 0;
41         const HASHTAG  = 1;
42         const MENTION  = 2;
43         const CATEGORY = 3;
44         const FILE     = 5;
45         /**
46          * An implicit mention is a mention in a comment body that is redundant with the threading information.
47          */
48         const IMPLICIT_MENTION  = 8;
49         /**
50          * An exclusive mention transfers the ownership of the post to the target account, usually a forum.
51          */
52         const EXCLUSIVE_MENTION = 9;
53
54         const TAG_CHARACTER = [
55                 self::HASHTAG           => '#',
56                 self::MENTION           => '@',
57                 self::IMPLICIT_MENTION  => '%',
58                 self::EXCLUSIVE_MENTION => '!',
59         ];
60
61         /**
62          * Store tag/mention elements
63          *
64          * @param integer $uriid
65          * @param integer $type
66          * @param string  $name
67          * @param string  $url
68          * @param boolean $probing
69          */
70         public static function store(int $uriid, int $type, string $name, string $url = '', $probing = true)
71         {
72                 if ($type == self::HASHTAG) {
73                         // Remove some common "garbarge" from tags
74                         $name = trim($name, "\x00..\x20\xFF#!@,;.:'/?!^°$%".'"');
75
76                         $tags = explode(self::TAG_CHARACTER[self::HASHTAG], $name);
77                         if (count($tags) > 1) {
78                                 foreach ($tags as $tag) {
79                                         self::store($uriid, $type, $tag, $url, $probing);
80                                 }
81                                 return;
82                         }
83                 }
84
85                 if (empty($name)) {
86                         return;
87                 }
88
89                 $cid = 0;
90                 $tagid = 0;
91
92                 if (in_array($type, [self::MENTION, self::EXCLUSIVE_MENTION, self::IMPLICIT_MENTION])) {
93                         if (empty($url)) {
94                                 // No mention without a contact url
95                                 return;
96                         }
97
98                         if (!$probing) {
99                                 $condition = ['nurl' => Strings::normaliseLink($url), 'uid' => 0, 'deleted' => false];
100                                 $contact = DBA::selectFirst('contact', ['id'], $condition, ['order' => ['id']]);
101                                 if (DBA::isResult($contact)) {
102                                         $cid = $contact['id'];
103                                         Logger::info('Got id for contact url', ['cid' => $cid, 'url' => $url]);
104                                 }
105
106                                 if (empty($cid)) {
107                                         $ssl_url = str_replace('http://', 'https://', $url);
108                                         $condition = ['`alias` IN (?, ?, ?) AND `uid` = ? AND NOT `deleted`', $url, Strings::normaliseLink($url), $ssl_url, 0];
109                                         $contact = DBA::selectFirst('contact', ['id'], $condition, ['order' => ['id']]);
110                                         if (DBA::isResult($contact)) {
111                                                 $cid = $contact['id'];
112                                                 Logger::info('Got id for contact alias', ['cid' => $cid, 'url' => $url]);
113                                         }
114                                 }
115                         } else {
116                                 $cid = Contact::getIdForURL($url, 0, true);
117                                 Logger::info('Got id by probing', ['cid' => $cid, 'url' => $url]);
118                         }
119
120                         if (empty($cid)) {
121                                 // The contact wasn't found in the system (most likely some dead account)
122                                 // We ensure that we only store a single entry by overwriting the previous name
123                                 Logger::info('Contact not found, updating tag', ['url' => $url, 'name' => $name]);
124                                 DBA::update('tag', ['name' => substr($name, 0, 96)], ['url' => $url]);
125                         }
126                 }
127
128                 if (empty($cid)) {
129                         $fields = ['name' => substr($name, 0, 96), 'url' => ''];
130
131                         if (($type != self::HASHTAG) && !empty($url) && ($url != $name)) {
132                                 $fields['url'] = strtolower($url);
133                         }
134
135                         $tag = DBA::selectFirst('tag', ['id'], $fields);
136                         if (!DBA::isResult($tag)) {
137                                 DBA::insert('tag', $fields, true);
138                                 $tagid = DBA::lastInsertId();
139                         } else {
140                                 $tagid = $tag['id'];
141                         }
142
143                         if (empty($tagid)) {
144                                 Logger::error('No tag id created', $fields);
145                                 return;
146                         }
147                 }
148
149                 $fields = ['uri-id' => $uriid, 'type' => $type, 'tid' => $tagid, 'cid' => $cid];
150
151                 if (in_array($type, [self::MENTION, self::EXCLUSIVE_MENTION, self::IMPLICIT_MENTION])) {
152                         $condition = $fields;
153                         $condition['type'] = [self::MENTION, self::EXCLUSIVE_MENTION, self::IMPLICIT_MENTION];
154                         if (DBA::exists('post-tag', $condition)) {
155                                 Logger::info('Tag already exists', $fields);
156                                 return;
157                         }
158                 }
159
160                 DBA::insert('post-tag', $fields, true);
161
162                 Logger::info('Stored tag/mention', ['uri-id' => $uriid, 'tag-id' => $tagid, 'contact-id' => $cid, 'name' => $name, 'type' => $type, 'callstack' => System::callstack(8)]);
163         }
164
165         /**
166          * Store tag/mention elements
167          *
168          * @param integer $uriid
169          * @param string $hash
170          * @param string $name
171          * @param string $url
172          * @param boolean $probing
173          */
174         public static function storeByHash(int $uriid, string $hash, string $name, string $url = '', $probing = true)
175         {
176                 $type = self::getTypeForHash($hash);
177                 if ($type == self::UNKNOWN) {
178                         return;
179                 }
180
181                 self::store($uriid, $type, $name, $url, $probing);
182         }
183
184         /**
185          * Store tags and mentions from the body
186          * 
187          * @param integer $uriid   URI-Id
188          * @param string  $body    Body of the post
189          * @param string  $tags    Accepted tags
190          * @param boolean $probing Perform a probing for contacts, adding them if needed
191          */
192         public static function storeFromBody(int $uriid, string $body, string $tags = null, $probing = true)
193         {
194                 if (is_null($tags)) {
195                         $tags =  self::TAG_CHARACTER[self::HASHTAG] . self::TAG_CHARACTER[self::MENTION] . self::TAG_CHARACTER[self::EXCLUSIVE_MENTION];
196                 }
197
198                 Logger::info('Check for tags', ['uri-id' => $uriid, 'hash' => $tags, 'callstack' => System::callstack()]);
199
200                 if (!preg_match_all("/([" . $tags . "])\[url\=([^\[\]]*)\]([^\[\]]*)\[\/url\]/ism", $body, $result, PREG_SET_ORDER)) {
201                         return;
202                 }
203
204                 Logger::info('Found tags', ['uri-id' => $uriid, 'hash' => $tags, 'result' => $result]);
205
206                 foreach ($result as $tag) {
207                         self::storeByHash($uriid, $tag[1], $tag[3], $tag[2], $probing);
208                 }
209         }
210
211         /**
212          * Store raw tags (not encapsulated in links) from the body
213          * This function is needed in the intermediate phase.
214          * Later we can call item::setHashtags in advance to have all tags converted.
215          * 
216          * @param integer $uriid URI-Id
217          * @param string  $body   Body of the post
218          */
219         public static function storeRawTagsFromBody(int $uriid, string $body)
220         {
221                 Logger::info('Check for tags', ['uri-id' => $uriid, 'callstack' => System::callstack()]);
222
223                 $result = BBCode::getTags($body);
224                 if (empty($result)) {
225                         return;
226                 }
227
228                 Logger::info('Found tags', ['uri-id' => $uriid, 'result' => $result]);
229
230                 foreach ($result as $tag) {
231                         if (substr($tag, 0, 1) != self::TAG_CHARACTER[self::HASHTAG]) {
232                                 continue;
233                         }
234                         self::storeByHash($uriid, substr($tag, 0, 1), substr($tag, 1));
235                 }
236         }
237
238         /**
239          * Checks for stored hashtags and mentions for the given post
240          *
241          * @param integer $uriid
242          * @return bool
243          */
244         public static function existsForPost(int $uriid)
245         {
246                 return DBA::exists('post-tag', ['uri-id' => $uriid, 'type' => [self::HASHTAG, self::MENTION, self::IMPLICIT_MENTION, self::EXCLUSIVE_MENTION]]);
247         }
248
249         /**
250          * Remove tag/mention
251          *
252          * @param integer $uriid
253          * @param integer $type
254          * @param string $name
255          * @param string $url
256          */
257         public static function remove(int $uriid, int $type, string $name, string $url = '')
258         {
259                 $condition = ['uri-id' => $uriid, 'type' => $type, 'url' => $url];
260                 if ($type == self::HASHTAG) {
261                         $condition['name'] = $name;
262                 }
263
264                 $tag = DBA::selectFirst('tag-view', ['tid', 'cid'], $condition);
265                 if (!DBA::isResult($tag)) {
266                         return;
267                 }
268
269                 Logger::info('Removing tag/mention', ['uri-id' => $uriid, 'tid' => $tag['tid'], 'name' => $name, 'url' => $url, 'callstack' => System::callstack(8)]);
270                 DBA::delete('post-tag', ['uri-id' => $uriid, 'type' => $type, 'tid' => $tag['tid'], 'cid' => $tag['cid']]);
271         }
272
273         /**
274          * Remove tag/mention
275          *
276          * @param integer $uriid
277          * @param string $hash
278          * @param string $name
279          * @param string $url
280          */
281         public static function removeByHash(int $uriid, string $hash, string $name, string $url = '')
282         {
283                 $type = self::getTypeForHash($hash);
284                 if ($type == self::UNKNOWN) {
285                         return;
286                 }
287
288                 self::remove($uriid, $type, $name, $url);
289         }
290
291         /**
292          * Get the type for the given hash
293          *
294          * @param string $hash
295          * @return integer type
296          */
297         private static function getTypeForHash(string $hash)
298         {
299                 if ($hash == self::TAG_CHARACTER[self::MENTION]) {
300                         return self::MENTION;
301                 } elseif ($hash == self::TAG_CHARACTER[self::EXCLUSIVE_MENTION]) {
302                         return self::EXCLUSIVE_MENTION;
303                 } elseif ($hash == self::TAG_CHARACTER[self::IMPLICIT_MENTION]) {
304                         return self::IMPLICIT_MENTION;
305                 } elseif ($hash == self::TAG_CHARACTER[self::HASHTAG]) {
306                         return self::HASHTAG;
307                 } else {
308                         return self::UNKNOWN;
309                 }
310         }
311
312         /**
313          * Retrieves the terms from the provided type(s) associated with the provided item ID.
314          *
315          * @param int       $item_id
316          * @param int|array $type
317          * @return array
318          * @throws \Exception
319          */
320         public static function getByURIId(int $uri_id, array $type = [self::HASHTAG, self::MENTION, self::IMPLICIT_MENTION, self::EXCLUSIVE_MENTION])
321         {
322                 $condition = ['uri-id' => $uri_id, 'type' => $type];
323                 $tags = DBA::select('tag-view', ['type', 'name', 'url'], $condition);
324                 if (!DBA::isResult($tags)) {
325                         return [];
326                 }
327
328                 $tag_list = [];
329                 while ($tag = DBA::fetch($tags)) {
330                         $tag['term'] = $tag['name']; /// @todo Remove this line when all occurrences of "term" had been replaced with "name"
331                         $tag_list[] = $tag;
332                 }
333
334                 return $tag_list;
335         }
336
337         /**
338          * Sorts an item's tags into mentions, hashtags and other tags. Generate personalized URLs by user and modify the
339          * provided item's body with them.
340          *
341          * @param array $item
342          * @return array
343          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
344          * @throws \ImagickException
345          */
346         public static function populateFromItem(&$item)
347         {
348                 $return = [
349                         'tags' => [],
350                         'hashtags' => [],
351                         'mentions' => [],
352                         'implicit_mentions' => [],
353                 ];
354
355                 $searchpath = DI::baseUrl() . "/search?tag=";
356
357                 $taglist = DBA::select('tag-view', ['type', 'name', 'url'],
358                         ['uri-id' => $item['uri-id'], 'type' => [self::HASHTAG, self::MENTION, self::EXCLUSIVE_MENTION, self::IMPLICIT_MENTION]]);
359                 while ($tag = DBA::fetch($taglist)) {
360                         if ($tag['url'] == '') {
361                                 $tag['url'] = $searchpath . rawurlencode($tag['name']);
362                         }
363
364                         $orig_tag = $tag['url'];
365
366                         $prefix = self::TAG_CHARACTER[$tag['type']];
367                         switch($tag['type']) {
368                                 case self::HASHTAG:
369                                         if ($orig_tag != $tag['url']) {
370                                                 $item['body'] = str_replace($orig_tag, $tag['url'], $item['body']);
371                                         }
372
373                                         $return['hashtags'][] = $prefix . '<a href="' . $tag['url'] . '" target="_blank" rel="noopener noreferrer">' . htmlspecialchars($tag['name']) . '</a>';
374                                         $return['tags'][] = $prefix . '<a href="' . $tag['url'] . '" target="_blank" rel="noopener noreferrer">' . htmlspecialchars($tag['name']) . '</a>';
375                                         break;
376                                 case self::MENTION:
377                                 case self::EXCLUSIVE_MENTION:
378                                                 $tag['url'] = Contact::magicLink($tag['url']);
379                                         $return['mentions'][] = $prefix . '<a href="' . $tag['url'] . '" target="_blank" rel="noopener noreferrer">' . htmlspecialchars($tag['name']) . '</a>';
380                                         $return['tags'][] = $prefix . '<a href="' . $tag['url'] . '" target="_blank" rel="noopener noreferrer">' . htmlspecialchars($tag['name']) . '</a>';
381                                         break;
382                                 case self::IMPLICIT_MENTION:
383                                         $return['implicit_mentions'][] = $prefix . $tag['name'];
384                                         break;
385                         }
386                 }
387                 DBA::close($taglist);
388
389                 return $return;
390         }
391
392         /**
393          * Search posts for given tag
394          *
395          * @param string $search
396          * @param integer $uid
397          * @param integer $start
398          * @param integer $limit
399          * @return array with URI-ID
400          */
401         public static function getURIIdListForTag(string $search, int $uid = 0, int $start = 0, int $limit = 100)
402         {
403                 $condition = ["`name` = ? AND (NOT `private` OR (`private` AND `uid` = ?))", $search, $uid];
404                 $params = [
405                         'order' => ['uri-id' => true],
406                         'group_by' => ['uri-id'],
407                         'limit' => [$start, $limit]
408                 ];
409
410                 $tags = DBA::select('tag-search-view', ['uri-id'], $condition, $params);
411
412                 $uriids = [];
413                 while ($tag = DBA::fetch($tags)) {
414                         $uriids[] = $tag['uri-id'];
415                 }
416                 DBA::close($tags);
417
418                 return $uriids;
419         }
420
421         /**
422          * Returns a list of the most frequent global hashtags over the given period
423          *
424          * @param int $period Period in hours to consider posts
425          * @return array
426          * @throws \Exception
427          */
428         public static function getGlobalTrendingHashtags(int $period, $limit = 10)
429         {
430                 $tags = DI::cache()->get('global_trending_tags');
431
432                 if (empty($tags)) {
433                         $tagsStmt = DBA::p("SELECT `name` AS `term`, COUNT(*) AS `score`
434                                 FROM `tag-search-view`
435                                 WHERE `private` = ? AND `received` > DATE_SUB(NOW(), INTERVAL ? HOUR)
436                                 GROUP BY `term` ORDER BY `score` DESC LIMIT ?",
437                                 Item::PUBLIC, $period, $limit);
438
439                         if (DBA::isResult($tagsStmt)) {
440                                 $tags = DBA::toArray($tagsStmt);
441                                 DI::cache()->set('global_trending_tags', $tags, Duration::HOUR);
442                         }
443                 }
444
445                 return $tags ?: [];
446         }
447
448         /**
449          * Returns a list of the most frequent local hashtags over the given period
450          *
451          * @param int $period Period in hours to consider posts
452          * @return array
453          * @throws \Exception
454          */
455         public static function getLocalTrendingHashtags(int $period, $limit = 10)
456         {
457                 $tags = DI::cache()->get('local_trending_tags');
458
459                 if (empty($tags)) {
460                         $tagsStmt = DBA::p("SELECT `name` AS `term`, COUNT(*) AS `score`
461                                 FROM `tag-search-view`
462                                 WHERE `private` = ? AND `wall` AND `origin` AND `received` > DATE_SUB(NOW(), INTERVAL ? HOUR)
463                                 GROUP BY `term` ORDER BY `score` DESC LIMIT ?",
464                                 Item::PUBLIC, $period, $limit);
465
466                         if (DBA::isResult($tagsStmt)) {
467                                 $tags = DBA::toArray($tagsStmt);
468                                 DI::cache()->set('local_trending_tags', $tags, Duration::HOUR);
469                         }
470                 }
471
472                 return $tags ?: [];
473         }
474 }