]> git.mxchange.org Git - friendica.git/blob - src/Model/Term.php
Merge pull request #8566 from annando/write-tags
[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\Logger;
25 use Friendica\Database\DBA;
26 use Friendica\DI;
27 use Friendica\Util\Strings;
28
29 /**
30  * Class Term
31  *
32  * This Model class handles term table interactions.
33  * This tables stores relevant terms related to posts, photos and searches, like hashtags, mentions and
34  * user-applied categories.
35  */
36 class Term
37 {
38     const UNKNOWN           = 0;
39     const HASHTAG           = 1;
40     const MENTION           = 2;
41     const CATEGORY          = 3;
42     const FILE              = 5;
43         /**
44          * An implicit mention is a mention in a comment body that is redundant with the threading information.
45          */
46     const IMPLICIT_MENTION  = 8;
47         /**
48          * An exclusive mention transfers the ownership of the post to the target account, usually a forum.
49          */
50     const EXCLUSIVE_MENTION = 9;
51
52     const TAG_CHARACTER = [
53         self::HASHTAG           => '#',
54         self::MENTION           => '@',
55         self::IMPLICIT_MENTION  => '%',
56         self::EXCLUSIVE_MENTION => '!',
57     ];
58
59     const OBJECT_TYPE_POST  = 1;
60     const OBJECT_TYPE_PHOTO = 2;
61
62         /**
63          * Generates the legacy item.tag field comma-separated BBCode string from an item ID.
64          * Includes only hashtags, implicit and explicit mentions.
65          *
66          * @param int $item_id
67          * @return string
68          * @throws \Exception
69          */
70         public static function tagTextFromItemId($item_id)
71         {
72                 $tag_list = [];
73                 $tags = self::getByItemId($item_id, [self::HASHTAG, self::MENTION, self::IMPLICIT_MENTION]);
74                 foreach ($tags as $tag) {
75                         $tag_list[] = self::TAG_CHARACTER[$tag['type']] . '[url=' . $tag['url'] . ']' . $tag['term'] . '[/url]';
76                 }
77
78                 return implode(',', $tag_list);
79         }
80
81         /**
82          * Retrieves the terms from the provided type(s) associated with the provided item ID.
83          *
84          * @param int       $item_id
85          * @param int|array $type
86          * @return array
87          * @throws \Exception
88          */
89         private static function getByItemId($item_id, $type = [self::HASHTAG, self::MENTION])
90         {
91                 $condition = ['otype' => self::OBJECT_TYPE_POST, 'oid' => $item_id, 'type' => $type];
92                 $tags = DBA::select('term', ['type', 'term', 'url'], $condition);
93                 if (!DBA::isResult($tags)) {
94                         return [];
95                 }
96
97                 return DBA::toArray($tags);
98         }
99
100         /**
101          * Generates the legacy item.file field string from an item ID.
102          * Includes only file and category terms.
103          *
104          * @param int $item_id
105          * @return string
106          * @throws \Exception
107          */
108         public static function fileTextFromItemId($item_id)
109         {
110                 $file_text = '';
111                 $tags = self::getByItemId($item_id, [self::FILE, self::CATEGORY]);
112                 foreach ($tags as $tag) {
113                         if ($tag['type'] == self::CATEGORY) {
114                                 $file_text .= '<' . $tag['term'] . '>';
115                         } else {
116                                 $file_text .= '[' . $tag['term'] . ']';
117                         }
118                 }
119
120                 return $file_text;
121         }
122
123         /**
124          * Inserts new terms for the provided item ID based on the legacy item.tag field BBCode content.
125          * Deletes all previous tag terms for the same item ID.
126          * Sets both the item.mention and thread.mentions field flags if a mention concerning the item UID is found.
127          *
128          * @param int    $item_id
129          * @param string $tag_str
130          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
131          */
132         public static function insertFromTagFieldByItemId($item_id, $tag_str)
133         {
134                 $profile_base = DI::baseUrl();
135                 $profile_data = parse_url($profile_base);
136                 $profile_path = $profile_data['path'] ?? '';
137                 $profile_base_friendica = $profile_data['host'] . $profile_path . '/profile/';
138                 $profile_base_diaspora = $profile_data['host'] . $profile_path . '/u/';
139
140                 $fields = ['guid', 'uid', 'id', 'edited', 'deleted', 'created', 'received', 'title', 'body', 'parent'];
141                 $item = Item::selectFirst($fields, ['id' => $item_id]);
142                 if (!DBA::isResult($item)) {
143                         return;
144                 }
145
146                 $item['tag'] = $tag_str;
147
148                 // Clean up all tags
149                 self::deleteByItemId($item_id);
150
151                 if ($item['deleted']) {
152                         return;
153                 }
154
155                 $taglist = explode(',', $item['tag']);
156
157                 $tags_string = '';
158                 foreach ($taglist as $tag) {
159                         if (Strings::startsWith($tag, self::TAG_CHARACTER)) {
160                                 $tags_string .= ' ' . trim($tag);
161                         } else {
162                                 $tags_string .= ' #' . trim($tag);
163                         }
164                 }
165
166                 $data = ' ' . $item['title'] . ' ' . $item['body'] . ' ' . $tags_string . ' ';
167
168                 // ignore anything in a code block
169                 $data = preg_replace('/\[code\](.*?)\[\/code\]/sm', '', $data);
170
171                 $tags = [];
172
173                 $pattern = '/\W\#([^\[].*?)[\s\'".,:;\?!\[\]\/]/ism';
174                 if (preg_match_all($pattern, $data, $matches)) {
175                         foreach ($matches[1] as $match) {
176                                 $tags['#' . $match] = '';
177                         }
178                 }
179
180                 $pattern = '/\W([\#@!%])\[url\=(.*?)\](.*?)\[\/url\]/ism';
181                 if (preg_match_all($pattern, $data, $matches, PREG_SET_ORDER)) {
182                         foreach ($matches as $match) {
183
184                                 if (in_array($match[1], [
185                                         self::TAG_CHARACTER[self::MENTION],
186                                         self::TAG_CHARACTER[self::IMPLICIT_MENTION],
187                                         self::TAG_CHARACTER[self::EXCLUSIVE_MENTION]
188                                 ])) {
189                                         $contact = Contact::getDetailsByURL($match[2], 0);
190                                         if (!empty($contact['addr'])) {
191                                                 $match[3] = $contact['addr'];
192                                         }
193
194                                         if (!empty($contact['url'])) {
195                                                 $match[2] = $contact['url'];
196                                         }
197                                 }
198
199                                 $tags[$match[2]] = $match[1] . trim($match[3], ',.:;[]/\"?!');
200                         }
201                 }
202
203                 foreach ($tags as $link => $tag) {
204                         if (Tag::isType($tag, self::HASHTAG)) {
205                                 // try to ignore #039 or #1 or anything like that
206                                 if (ctype_digit(substr(trim($tag), 1))) {
207                                         continue;
208                                 }
209
210                                 // try to ignore html hex escapes, e.g. #x2317
211                                 if ((substr(trim($tag), 1, 1) == 'x' || substr(trim($tag), 1, 1) == 'X') && ctype_digit(substr(trim($tag), 2))) {
212                                         continue;
213                                 }
214
215                                 $type = self::HASHTAG;
216                                 $term = substr($tag, 1);
217                                 $link = '';
218                         } elseif (Tag::isType($tag, self::MENTION, self::EXCLUSIVE_MENTION, self::IMPLICIT_MENTION)) {
219                                 if (Tag::isType($tag, self::MENTION, self::EXCLUSIVE_MENTION)) {
220                                         $type = self::MENTION;
221                                 } else {
222                                         $type = self::IMPLICIT_MENTION;
223                                 }
224
225                                 $contact = Contact::getDetailsByURL($link, 0);
226                                 if (!empty($contact['name'])) {
227                                         $term = $contact['name'];
228                                 } else {
229                                         $term = substr($tag, 1);
230                                 }
231                         } else { // This shouldn't happen
232                                 $type = self::HASHTAG;
233                                 $term = $tag;
234                                 $link = '';
235
236                                 Logger::notice('Unknown term type', ['tag' => $tag]);
237                         }
238
239                         if (DBA::exists('term', ['uid' => $item['uid'], 'otype' => self::OBJECT_TYPE_POST, 'oid' => $item_id, 'term' => $term, 'type' => $type])) {
240                                 continue;
241                         }
242
243                         if (empty($term)) {
244                                 continue;
245                         }
246
247                         if ($item['uid'] == 0) {
248                                 $global = true;
249                                 DBA::update('term', ['global' => true], ['otype' => self::OBJECT_TYPE_POST, 'guid' => $item['guid']]);
250                         } else {
251                                 $global = DBA::exists('term', ['uid' => 0, 'otype' => self::OBJECT_TYPE_POST, 'guid' => $item['guid']]);
252                         }
253
254                         DBA::insert('term', [
255                                 'uid'      => $item['uid'],
256                                 'oid'      => $item_id,
257                                 'otype'    => self::OBJECT_TYPE_POST,
258                                 'type'     => $type,
259                                 'term'     => substr($term, 0, 255),
260                                 'url'      => $link,
261                                 'guid'     => $item['guid'],
262                                 'created'  => $item['created'],
263                                 'received' => $item['received'],
264                                 'global'   => $global
265                         ]);
266
267                         // Search for mentions
268                         if (Tag::isType($tag, self::MENTION, self::EXCLUSIVE_MENTION)
269                                 && (
270                                         strpos($link, $profile_base_friendica) !== false
271                                         || strpos($link, $profile_base_diaspora) !== false
272                                 )
273                         ) {
274                                 $users_stmt = DBA::p("SELECT `uid` FROM `contact` WHERE self AND (`url` = ? OR `nurl` = ?)", $link, $link);
275                                 $users = DBA::toArray($users_stmt);
276                                 foreach ($users AS $user) {
277                                         if ($user['uid'] == $item['uid']) {
278                                                 /// @todo This function is called from Item::update - so we mustn't call that function here
279                                                 DBA::update('item', ['mention' => true], ['id' => $item_id]);
280                                                 DBA::update('thread', ['mention' => true], ['iid' => $item['parent']]);
281                                         }
282                                 }
283                         }
284                 }
285         }
286
287         /**
288          * Inserts new terms for the provided item ID based on the legacy item.file field BBCode content.
289          * Deletes all previous file terms for the same item ID.
290          *
291          * @param integer $item_id item id
292          * @param         $files
293          * @return void
294          * @throws \Exception
295          */
296         public static function insertFromFileFieldByItemId($item_id, $files)
297         {
298                 $message = Item::selectFirst(['uid', 'deleted'], ['id' => $item_id]);
299                 if (!DBA::isResult($message)) {
300                         return;
301                 }
302
303                 // Clean up all tags
304                 DBA::delete('term', ['otype' => self::OBJECT_TYPE_POST, 'oid' => $item_id, 'type' => [self::FILE, self::CATEGORY]]);
305
306                 if ($message["deleted"]) {
307                         return;
308                 }
309
310                 $message['file'] = $files;
311
312                 if (preg_match_all("/\[(.*?)\]/ism", $message["file"], $files)) {
313                         foreach ($files[1] as $file) {
314                                 DBA::insert('term', [
315                                         'uid' => $message["uid"],
316                                         'oid' => $item_id,
317                                         'otype' => self::OBJECT_TYPE_POST,
318                                         'type' => self::FILE,
319                                         'term' => $file
320                                 ]);
321                         }
322                 }
323
324                 if (preg_match_all("/\<(.*?)\>/ism", $message["file"], $files)) {
325                         foreach ($files[1] as $file) {
326                                 DBA::insert('term', [
327                                         'uid' => $message["uid"],
328                                         'oid' => $item_id,
329                                         'otype' => self::OBJECT_TYPE_POST,
330                                         'type' => self::CATEGORY,
331                                         'term' => $file
332                                 ]);
333                         }
334                 }
335         }
336
337         /**
338          * Delete tags of the specific type(s) from an item
339          *
340          * @param int       $item_id
341          * @param int|array $type
342          * @throws \Exception
343          */
344         public static function deleteByItemId($item_id, $type = [self::HASHTAG, self::MENTION, self::IMPLICIT_MENTION])
345         {
346                 if (empty($item_id)) {
347                         return;
348                 }
349
350                 // Clean up all tags
351                 DBA::delete('term', ['otype' => self::OBJECT_TYPE_POST, 'oid' => $item_id, 'type' => $type]);
352         }
353 }