]> git.mxchange.org Git - friendica.git/blobdiff - src/Model/Item.php
Improved handling for undeterminded languages
[friendica.git] / src / Model / Item.php
index 27359ab2bf89e211ef08894f697d3f367e91ded7..a672f5b174791aa7af0d61f6446bd45eb4188812 100644 (file)
@@ -1,6 +1,6 @@
 <?php
 /**
- * @copyright Copyright (C) 2010-2023, the Friendica project
+ * @copyright Copyright (C) 2010-2024, the Friendica project
  *
  * @license GNU AGPL version 3 or any later version
  *
@@ -117,7 +117,7 @@ class Item
        const DELIVER_FIELDLIST = [
                'uid', 'id', 'parent', 'uri-id', 'uri', 'thr-parent', 'parent-uri', 'guid',
                'parent-guid', 'conversation', 'received', 'created', 'edited', 'verb', 'object-type', 'object', 'target',
-               'private', 'title', 'body', 'raw-body', 'location', 'coord', 'app',
+               'private', 'title', 'body', 'raw-body', 'language', 'location', 'coord', 'app',
                'inform', 'deleted', 'extid', 'post-type', 'post-reason', 'gravity',
                'allow_cid', 'allow_gid', 'deny_cid', 'deny_gid',
                'author-id', 'author-addr', 'author-link', 'author-name', 'author-avatar', 'owner-id', 'owner-link', 'contact-uid',
@@ -138,7 +138,7 @@ class Item
                'allow_cid', 'allow_gid', 'deny_cid', 'deny_gid', 'post-type', 'post-reason',
                'private', 'pubmail', 'visible', 'starred',
                'unseen', 'deleted', 'origin', 'mention', 'global', 'network',
-               'title', 'content-warning', 'body', 'location', 'coord', 'app',
+               'title', 'content-warning', 'body', 'language', 'location', 'coord', 'app',
                'rendered-hash', 'rendered-html', 'object-type', 'object', 'target-type', 'target',
                'author-id', 'author-link', 'author-name', 'author-avatar', 'author-network',
                'owner-id', 'owner-link', 'owner-name', 'owner-avatar', 'causer-id'
@@ -196,6 +196,10 @@ class Item
                        $previous = Post::selectFirst(['edited'], $condition);
                }
 
+               if (!empty($fields['body'])) {
+                       $fields['body'] = self::setHashtags($fields['body']);
+               }
+
                $rows = Post::update($fields, $condition);
                if (is_bool($rows)) {
                        return $rows;
@@ -330,11 +334,11 @@ class Item
         */
        public static function markForDeletionById(int $item_id, int $priority = Worker::PRIORITY_HIGH): bool
        {
-               Logger::info('Mark item for deletion by id', ['id' => $item_id, 'callstack' => System::callstack()]);
+               Logger::info('Mark item for deletion by id', ['id' => $item_id]);
                // locate item to be deleted
                $fields = [
                        'id', 'uri', 'uri-id', 'uid', 'parent', 'parent-uri-id', 'origin',
-                       'deleted', 'resource-id', 'event-id',
+                       'thr-parent-id', 'deleted', 'resource-id', 'event-id', 'vid', 'body',
                        'verb', 'object-type', 'object', 'target', 'contact-id', 'psid', 'gravity'
                ];
                $item = Post::selectFirst($fields, ['id' => $item_id]);
@@ -414,6 +418,10 @@ class Item
                DI::notify()->deleteForItem($item['uri-id']);
                DI::notification()->deleteForItem($item['uri-id']);
 
+               if (in_array($item['gravity'], [self::GRAVITY_ACTIVITY, self::GRAVITY_COMMENT])) {
+                       Post\Counts::update($item['thr-parent-id'], $item['parent-uri-id'], $item['vid'], $item['verb'], $item['body']);
+               }
+
                Logger::info('Item has been marked for deletion.', ['id' => $item_id]);
 
                return true;
@@ -795,7 +803,7 @@ class Item
                }
 
                if (!DBA::isResult($parent)) {
-                       Logger::notice('item parent was not found - ignoring item', ['uri-id' => $item['uri-id'], 'thr-parent-id' => $item['thr-parent-id'], 'uid' => $item['uid'], 'callstack' => System::callstack(20)]);
+                       Logger::notice('item parent was not found - ignoring item', ['uri-id' => $item['uri-id'], 'thr-parent-id' => $item['thr-parent-id'], 'uid' => $item['uid']]);
                        return [];
                }
 
@@ -880,6 +888,10 @@ class Item
                        if (is_int($notify) && in_array($notify, Worker::PRIORITIES)) {
                                $priority = $notify;
                        }
+
+                       // Mastodon style API visibility
+                       $copy_permissions = ($item['visibility'] ?? 'private') == 'private';
+                       unset($item['visibility']);
                } else {
                        $item['network'] = trim(($item['network'] ?? '') ?: Protocol::PHANTOM);
                }
@@ -1038,10 +1050,12 @@ class Item
 
                        // Reshares have to keep their permissions to allow groups to work
                        if (!$defined_permissions && (!$item['origin'] || ($item['verb'] != Activity::ANNOUNCE))) {
-                               $item['allow_cid']     = $toplevel_parent['allow_cid'];
-                               $item['allow_gid']     = $toplevel_parent['allow_gid'];
-                               $item['deny_cid']      = $toplevel_parent['deny_cid'];
-                               $item['deny_gid']      = $toplevel_parent['deny_gid'];
+                               // Don't store the permissions on pure AP posts
+                               $store_permissions = ($item['network'] != Protocol::ACTIVITYPUB) || $item['origin'] || !empty($item['diaspora_signed_text']);
+                               $item['allow_cid'] = $store_permissions ? $toplevel_parent['allow_cid'] : '';
+                               $item['allow_gid'] = $store_permissions ? $toplevel_parent['allow_gid'] : '';
+                               $item['deny_cid']  = $store_permissions ? $toplevel_parent['deny_cid'] : '';
+                               $item['deny_gid']  = $store_permissions ? $toplevel_parent['deny_gid'] : '';
                        }
 
                        $parent_origin         = $toplevel_parent['origin'];
@@ -1183,7 +1197,7 @@ class Item
                        if (!empty($quote_id)) {
                                // This is one of these "should not happen" situations.
                                // The protocol implementations should already have done this job.
-                               Logger::notice('Quote-uri-id detected in post', ['id' => $quote_id, 'guid' => $item['guid'], 'uri-id' => $item['uri-id'], 'callstack' => System::callstack(20)]);
+                               Logger::notice('Quote-uri-id detected in post', ['id' => $quote_id, 'guid' => $item['guid'], 'uri-id' => $item['uri-id']]);
                                $item['quote-uri-id'] = $quote_id;
                        }
                }
@@ -1359,6 +1373,9 @@ class Item
 
                if ($notify) {
                        DI::contentItem()->postProcessPost($posted_item);
+                       if ($copy_permissions && ($posted_item['thr-parent-id'] != $posted_item['uri-id']) && ($posted_item['private'] == self::PRIVATE)) {
+                               DI::contentItem()->copyPermissions($posted_item['thr-parent-id'], $posted_item['uri-id'], $posted_item['parent-uri-id']);
+                       }
                } else {
                        Hook::callAll('post_remote_end', $posted_item);
                }
@@ -1405,25 +1422,59 @@ class Item
                }
 
                if (!empty($source) && ($transmit || DI::config()->get('debug', 'store_source'))) {
-                       Post\Activity::insert($item['uri-id'], $source);
+                       Post\Activity::insert($posted_item['uri-id'], $source);
                }
 
                if ($transmit) {
-                       Worker::add(['priority' => $priority, 'dont_fork' => true], 'Notifier', $notify_type, (int)$posted_item['uri-id'], (int)$posted_item['uid']);
-               }
+                       ActivityPub\Transmitter::storeReceiversForItem($posted_item);
 
-               // Fill the cache with the rendered content.
-               if (in_array($posted_item['gravity'], [self::GRAVITY_PARENT, self::GRAVITY_COMMENT]) && ($posted_item['uid'] == 0)) {
-                       self::updateDisplayCache($posted_item['uri-id']);
+                       Worker::add(['priority' => $priority, 'dont_fork' => true], 'Notifier', $notify_type, (int)$posted_item['uri-id'], (int)$posted_item['uid']);
                }
 
                if ($inserted) {
-                       Post\Engagement::storeFromItem($posted_item);
+                       // Fill the cache with the rendered content.
+                       if (in_array($posted_item['gravity'], [self::GRAVITY_PARENT, self::GRAVITY_COMMENT])) {
+                               self::updateDisplayCache($posted_item['uri-id']);
+                       }
+
+                       if (in_array($posted_item['gravity'], [self::GRAVITY_ACTIVITY, self::GRAVITY_COMMENT])) {
+                               Post\Counts::update($posted_item['thr-parent-id'], $posted_item['parent-uri-id'], $posted_item['vid'], $posted_item['verb'], $posted_item['body']);
+                       }
+
+                       $engagement_uri_id = Post\Engagement::storeFromItem($posted_item);
+                       if ($engagement_uri_id) {
+                               self::reshareChannelPost($engagement_uri_id);
+                       }
                }
 
                return $post_user_id;
        }
 
+       private static function reshareChannelPost(int $uri_id)
+       {
+               $item = Post::selectFirst(['id', 'private', 'network', 'language', 'owner-id'], ['uri-id' => $uri_id, 'uid' => 0]);
+               if (empty($item['id'])) {
+                       return;
+               }
+
+               if (($item['private'] != self::PUBLIC) || !in_array($item['network'], [Protocol::ACTIVITYPUB, Protocol::DFRN])) {
+                       return;
+               }
+
+               $engagement = DBA::selectFirst('post-engagement', ['searchtext', 'media-type'], ['uri-id' => $uri_id]);
+               if (empty($engagement['searchtext'])) {
+                       return;
+               }
+
+               $language = !empty($item['language']) ? array_key_first(json_decode($item['language'], true)) : '';
+               $tags     = array_column(Tag::getByURIId($uri_id, [Tag::HASHTAG]), 'name');
+
+               foreach (DI::userDefinedChannel()->getMatchingChannelUsers($engagement['searchtext'], $language, $tags, $engagement['media-type'], $item['owner-id']) as $uid) {
+                       Logger::debug('Reshare post', ['uid' => $uid, 'uri-id' => $uri_id, 'language' => $language, 'tags' => $tags, 'searchtext' => $engagement['searchtext'], 'media_type' => $engagement['media-type']]);
+                       self::performActivity($item['id'], 'announce', $uid);
+               }
+       }
+
        /**
         * Fetch the post reason for a given item array
         *
@@ -1475,6 +1526,10 @@ class Item
         */
        private static function setOwnerforResharedItem(array $item)
        {
+               if ($item['uid'] == 0) {
+                       return;
+               }
+
                $parent = Post::selectFirst(
                        ['id', 'causer-id', 'owner-id', 'author-id', 'author-link', 'origin', 'post-reason'],
                        ['uri-id' => $item['thr-parent-id'], 'uid' => $item['uid']]
@@ -1526,7 +1581,25 @@ class Item
                        return;
                }
 
+               $languages = $item['language'] ? array_keys(json_decode($item['language'], true)) : [];
+               
                foreach (Tag::getUIDListByURIId($item['uri-id']) as $uid => $tags) {
+                       if (!empty($languages)) {
+                               $keep = false;
+                               $user_languages = User::getWantedLanguages($uid);
+                               foreach ($user_languages as $language) {
+                                       if (in_array($language, $languages)) {
+                                               $keep = true;
+                                       }
+                               }
+                               if ($keep) {
+                                       Logger::debug('Wanted languages found', ['uid' => $uid, 'user-languages' => $user_languages, 'item-languages' => $languages]);
+                               } else {
+                                       Logger::debug('No wanted languages found', ['uid' => $uid, 'user-languages' => $user_languages, 'item-languages' => $languages]);
+                                       continue;
+                               }
+                       }
+
                        $stored = self::storeForUserByUriId($item['uri-id'], $uid, ['post-reason' => self::PR_TAG]);
                        Logger::info('Stored item for users', ['uri-id' => $item['uri-id'], 'uid' => $uid, 'stored' => $stored]);
                        foreach ($tags as $tag) {
@@ -1981,19 +2054,31 @@ class Item
         * @return string detected language
         * @throws \Text_LanguageDetect_Exception
         */
-       private static function getLanguage(array $item): string
+       private static function getLanguage(array $item): ?string
        {
                if (!empty($item['language'])) {
                        return $item['language'];
                }
 
-               if (!in_array($item['gravity'], [self::GRAVITY_PARENT, self::GRAVITY_COMMENT]) || empty($item['body'])) {
-                       return '';
+               $transmitted = [];
+               foreach ($item['transmitted-languages'] ??  [] as $language) {
+                       $transmitted[$language] = 0;
                }
 
-               $languages = self::getLanguageArray($item['title'] . ' ' . ($item['content-warning'] ?? '') . ' ' . $item['body'], 3, $item['uri-id'], $item['author-id']);
+               $content = trim(($item['title'] ?? '') . ' ' . ($item['content-warning'] ?? '') . ' ' . ($item['body'] ?? ''));
+
+               if (!in_array($item['gravity'], [self::GRAVITY_PARENT, self::GRAVITY_COMMENT]) || empty($content)) {
+                       return !empty($transmitted) ? json_encode($transmitted) : null;
+               }
+
+               $languages = self::getLanguageArray($content, 3, $item['uri-id'], $item['author-id']);
                if (empty($languages)) {
-                       return '';
+                       return !empty($transmitted) ? json_encode($transmitted) : null;
+               }
+
+               if (!empty($transmitted)) {
+                       $languages = array_merge($transmitted, $languages);
+                       arsort($languages);
                }
 
                return json_encode($languages);
@@ -2022,18 +2107,15 @@ class Item
                }
 
                if (empty($searchtext)) {
-                       return [];
+                       return ['un' => 1];
                }
 
-               $availableLanguages = DI::l10n()->getAvailableLanguages(true);
-               $availableLanguages = DI::l10n()->convertForLanguageDetection($availableLanguages);
-
-               $ld = new Language(array_keys($availableLanguages));
+               $ld = new Language(DI::l10n()->getDetectableLanguages());
 
                $result = [];
 
                foreach (self::splitByBlocks($searchtext) as $block) {
-                       $languages = $ld->detect($block)->limit(0, $count)->close() ?: [];
+                       $languages = $ld->detect($block)->close() ?: [];
 
                        $data = [
                                'text'      => $block,
@@ -2048,10 +2130,36 @@ class Item
                        }
                }
 
+               if (empty($result)) {
+                       return ['un' => 1];
+               }
+
+               $result = self::compactLanguages($result);
+
                arsort($result);
-               $result = array_slice($result, 0, $count);
+               return array_slice($result, 0, $count);
+       }
 
-               return $result;
+       /**
+        * Concert the language code in the detection result to ISO 639-1.
+        * On duplicates the system uses the higher quality value.
+        *
+        * @param array $result
+        * @return array
+        */
+       private static function compactLanguages(array $result): array
+       {
+               $languages = [];
+               foreach ($result as $language => $quality) {
+                       if ($quality == 0) {
+                               continue;
+                       }
+                       $code = DI::l10n()->toISO6391($language);
+                       if (empty($languages[$code]) || ($languages[$code] < $quality)) {
+                               $languages[$code] = $quality;
+                       }
+               }
+               return $languages;
        }
 
        /**
@@ -2134,7 +2242,20 @@ class Item
 
                $used_languages = '';
                foreach (json_decode($item['language'], true) as $language => $reliability) {
-                       $used_languages .= $iso639->nativeByCode1(substr($language, 0, 2)) . ' (' . $iso639->languageByCode1(substr($language, 0, 2)) . ' - ' . $language . "): " . number_format($reliability, 5) . '\n';
+                       $code = DI::l10n()->toISO6391($language);
+
+                       if ($code == 'un') {
+                               $native = $language = DI::l10n()->t('Undetermined');
+                       } else {
+                               $native   = $iso639->nativeByCode1($code);
+                               $language = $iso639->languageByCode1($code);
+                       }
+
+                       if ($native != $language) {
+                               $used_languages .= DI::l10n()->t('%s (%s - %s): %s', $native, $language, $code, number_format($reliability, 5)) . '\n';
+                       } else {
+                               $used_languages .= DI::l10n()->t('%s (%s): %s', $native, $code, number_format($reliability, 5)) . '\n';
+                       }
                }
                $used_languages = DI::l10n()->t('Detected languages in this post:\n%s', $used_languages);
                return $used_languages;
@@ -2162,12 +2283,12 @@ class Item
 
                $hostPart = $host ?: $parsed['host'] ?? '';
                if (!$hostPart) {
-                       Logger::warning('Empty host GUID part', ['uri' => $uri, 'host' => $host, 'parsed' => $parsed, 'callstack' => System::callstack(10)]);
+                       Logger::warning('Empty host GUID part', ['uri' => $uri, 'host' => $host, 'parsed' => $parsed]);
                }
 
                // Glue it together to be able to make a hash from it
                if (!empty($parsed)) {
-                       $host_id = implode('/', $parsed);
+                       $host_id = implode('/', (array)$parsed);
                } else {
                        $host_id = $uri;
                }
@@ -2512,11 +2633,14 @@ class Item
                        $result = self::insert($datarray2);
                        Logger::info('remote-self post original item', ['contact' => $contact['url'], 'result' => $result, 'item' => $datarray2]);
                } else {
-                       $datarray['private'] = self::PUBLIC;
                        $datarray['app'] = 'Feed';
                        $result = true;
                }
 
+               if ($result) {
+                       unset($datarray['private']);
+               }
+
                return (bool)$result;
        }
 
@@ -3393,7 +3517,7 @@ class Item
                unset($urlparts['fragment']);
 
                try {
-                       $url = (string)Uri::fromParts($urlparts);
+                       $url = (string)Uri::fromParts((array)$urlparts);
                } catch (\InvalidArgumentException $e) {
                        DI::logger()->notice('Invalid URL', ['$url' => $url, '$urlparts' => $urlparts]);
                        /* See https://github.com/friendica/friendica/issues/12113
@@ -3483,11 +3607,8 @@ class Item
                                continue;
                        }
 
-                       if ($PostMedia->mimetype->type == 'image') {
-                               $preview_size = $PostMedia->width > $PostMedia->height ? Proxy::SIZE_MEDIUM : Proxy::SIZE_LARGE;
-                               $preview_url = DI::baseUrl() . $PostMedia->getPreviewPath($preview_size);
-                       } elseif ($PostMedia->preview) {
-                               $preview_size = Proxy::SIZE_LARGE;
+                       if ($PostMedia->mimetype->type == 'image' || $PostMedia->preview) {
+                               $preview_size = Proxy::SIZE_MEDIUM;
                                $preview_url = DI::baseUrl() . $PostMedia->getPreviewPath($preview_size);
                        } else {
                                $preview_size = 0;
@@ -3704,7 +3825,7 @@ class Item
                DI::profiler()->startRecording('rendering');
                $trailing = '';
                foreach ($PostMedias as $PostMedia) {
-                       if (strpos($item['body'], $PostMedia->url)) {
+                       if (strpos($item['body'], (string)$PostMedia->url)) {
                                continue;
                        }
 
@@ -3751,16 +3872,16 @@ class Item
                        foreach ($options as $key => $option) {
                                if ($question['voters'] > 0) {
                                        $percent = $option['replies'] / $question['voters'] * 100;
-                                       $options[$key]['vote'] = DI::l10n()->tt('%2$s (%3$d%%, %1$d vote)', '%2$s (%3$d%%, %1$d votes)', $option['replies'], $option['name'], round($percent, 1));
+                                       $options[$key]['vote'] = DI::l10n()->tt('%2$s (%3$d%%, %1$d vote)', '%2$s (%3$d%%, %1$d votes)', $option['replies'] ?? 0, $option['name'], round($percent, 1));
                                } else {
-                                       $options[$key]['vote'] = DI::l10n()->tt('%2$s (%1$d vote)', '%2$s (%1$d votes)', $option['replies'], $option['name']);
+                                       $options[$key]['vote'] = DI::l10n()->tt('%2$s (%1$d vote)', '%2$s (%1$d votes)', $option['replies'] ?? 0, $option['name']);
                                }
                        }
 
                        if (!empty($question['voters']) && !empty($question['endtime'])) {
-                               $summary = DI::l10n()->tt('%d voter. Poll end: %s', '%d voters. Poll end: %s', $question['voters'], Temporal::getRelativeDate($question['endtime']));
+                               $summary = DI::l10n()->tt('%d voter. Poll end: %s', '%d voters. Poll end: %s', $question['voters'] ?? 0, Temporal::getRelativeDate($question['endtime']));
                        } elseif (!empty($question['voters'])) {
-                               $summary = DI::l10n()->tt('%d voter.', '%d voters.', $question['voters']);
+                               $summary = DI::l10n()->tt('%d voter.', '%d voters.', $question['voters'] ?? 0);
                        } elseif (!empty($question['endtime'])) {
                                $summary = DI::l10n()->t('Poll end: %s', Temporal::getRelativeDate($question['endtime']));
                        } else {
@@ -3908,11 +4029,12 @@ class Item
         * Fetches item for given URI or plink
         *
         * @param string $uri
-        * @param integer $uid
+        * @param int    $uid
+        * @param int    $completion
         *
         * @return integer item id
         */
-       public static function fetchByLink(string $uri, int $uid = 0): int
+       public static function fetchByLink(string $uri, int $uid = 0, int $completion = ActivityPub\Receiver::COMPLETION_MANUAL): int
        {
                Logger::info('Trying to fetch link', ['uid' => $uid, 'uri' => $uri]);
                $item_id = self::searchByLink($uri, $uid);
@@ -3933,7 +4055,7 @@ class Item
                        return is_numeric($hookData['item_id']) ? $hookData['item_id'] : 0;
                }
 
-               $fetched_uri = ActivityPub\Processor::fetchMissingActivity($uri, [], '', ActivityPub\Receiver::COMPLETION_MANUAL, $uid);
+               $fetched_uri = ActivityPub\Processor::fetchMissingActivity($uri, [], '', $completion, $uid);
 
                if ($fetched_uri) {
                        $item_id = self::searchByLink($fetched_uri, $uid);
@@ -3993,7 +4115,7 @@ class Item
                }
 
                $url = $shared['message_id'] ?: $shared['link'];
-               $id = self::fetchByLink($url);
+               $id = self::fetchByLink($url, 0, ActivityPub\Receiver::COMPLETION_ASYNC);
                if (!$id) {
                        Logger::notice('Post could not be fetched.', ['url' => $url, 'uid' => $uid]);
                        return 0;