From 771020bd8453f75a3ea4d4ebf4041b080394af60 Mon Sep 17 00:00:00 2001 From: Cristian Scheid Date: Tue, 14 Jul 2026 11:13:44 -0300 Subject: [PATCH] feat(recent-files): allow grouping search results by mime type Signed-off-by: Cristian Scheid --- .../composer/composer/autoload_classmap.php | 2 + .../dav/composer/composer/autoload_static.php | 2 + apps/dav/lib/Connector/Sabre/FilesPlugin.php | 5 + .../dav/lib/Connector/Sabre/GroupableFile.php | 37 +++++ apps/dav/lib/Files/FileSearchBackend.php | 139 ++++++++++++++++-- apps/dav/lib/Server.php | 5 +- apps/dav/lib/Service/FileGroupingService.php | 117 +++++++++++++++ .../unit/Files/FileSearchBackendTest.php | 6 +- apps/files/lib/ConfigLexicon.php | 16 ++ 9 files changed, 311 insertions(+), 18 deletions(-) create mode 100644 apps/dav/lib/Connector/Sabre/GroupableFile.php create mode 100644 apps/dav/lib/Service/FileGroupingService.php diff --git a/apps/dav/composer/composer/autoload_classmap.php b/apps/dav/composer/composer/autoload_classmap.php index ef25c658b83a4..e53e834329ad4 100644 --- a/apps/dav/composer/composer/autoload_classmap.php +++ b/apps/dav/composer/composer/autoload_classmap.php @@ -239,6 +239,7 @@ 'OCA\\DAV\\Connector\\Sabre\\File' => $baseDir . '/../lib/Connector/Sabre/File.php', 'OCA\\DAV\\Connector\\Sabre\\FilesPlugin' => $baseDir . '/../lib/Connector/Sabre/FilesPlugin.php', 'OCA\\DAV\\Connector\\Sabre\\FilesReportPlugin' => $baseDir . '/../lib/Connector/Sabre/FilesReportPlugin.php', + 'OCA\\DAV\\Connector\\Sabre\\GroupableFile' => $baseDir . '/../lib/Connector/Sabre/GroupableFile.php', 'OCA\\DAV\\Connector\\Sabre\\LockPlugin' => $baseDir . '/../lib/Connector/Sabre/LockPlugin.php', 'OCA\\DAV\\Connector\\Sabre\\MaintenancePlugin' => $baseDir . '/../lib/Connector/Sabre/MaintenancePlugin.php', 'OCA\\DAV\\Connector\\Sabre\\MtimeSanitizer' => $baseDir . '/../lib/Connector/Sabre/MtimeSanitizer.php', @@ -416,6 +417,7 @@ 'OCA\\DAV\\Service\\AbsenceService' => $baseDir . '/../lib/Service/AbsenceService.php', 'OCA\\DAV\\Service\\ExampleContactService' => $baseDir . '/../lib/Service/ExampleContactService.php', 'OCA\\DAV\\Service\\ExampleEventService' => $baseDir . '/../lib/Service/ExampleEventService.php', + 'OCA\\DAV\\Service\\FileGroupingService' => $baseDir . '/../lib/Service/FileGroupingService.php', 'OCA\\DAV\\Settings\\Admin\\SystemAddressBookSettings' => $baseDir . '/../lib/Settings/Admin/SystemAddressBookSettings.php', 'OCA\\DAV\\Settings\\AvailabilitySettings' => $baseDir . '/../lib/Settings/AvailabilitySettings.php', 'OCA\\DAV\\Settings\\CalDAVSettings' => $baseDir . '/../lib/Settings/CalDAVSettings.php', diff --git a/apps/dav/composer/composer/autoload_static.php b/apps/dav/composer/composer/autoload_static.php index fad494f1c6fe6..8a510ebfab32d 100644 --- a/apps/dav/composer/composer/autoload_static.php +++ b/apps/dav/composer/composer/autoload_static.php @@ -254,6 +254,7 @@ class ComposerStaticInitDAV 'OCA\\DAV\\Connector\\Sabre\\File' => __DIR__ . '/..' . '/../lib/Connector/Sabre/File.php', 'OCA\\DAV\\Connector\\Sabre\\FilesPlugin' => __DIR__ . '/..' . '/../lib/Connector/Sabre/FilesPlugin.php', 'OCA\\DAV\\Connector\\Sabre\\FilesReportPlugin' => __DIR__ . '/..' . '/../lib/Connector/Sabre/FilesReportPlugin.php', + 'OCA\\DAV\\Connector\\Sabre\\GroupableFile' => __DIR__ . '/..' . '/../lib/Connector/Sabre/GroupableFile.php', 'OCA\\DAV\\Connector\\Sabre\\LockPlugin' => __DIR__ . '/..' . '/../lib/Connector/Sabre/LockPlugin.php', 'OCA\\DAV\\Connector\\Sabre\\MaintenancePlugin' => __DIR__ . '/..' . '/../lib/Connector/Sabre/MaintenancePlugin.php', 'OCA\\DAV\\Connector\\Sabre\\MtimeSanitizer' => __DIR__ . '/..' . '/../lib/Connector/Sabre/MtimeSanitizer.php', @@ -431,6 +432,7 @@ class ComposerStaticInitDAV 'OCA\\DAV\\Service\\AbsenceService' => __DIR__ . '/..' . '/../lib/Service/AbsenceService.php', 'OCA\\DAV\\Service\\ExampleContactService' => __DIR__ . '/..' . '/../lib/Service/ExampleContactService.php', 'OCA\\DAV\\Service\\ExampleEventService' => __DIR__ . '/..' . '/../lib/Service/ExampleEventService.php', + 'OCA\\DAV\\Service\\FileGroupingService' => __DIR__ . '/..' . '/../lib/Service/FileGroupingService.php', 'OCA\\DAV\\Settings\\Admin\\SystemAddressBookSettings' => __DIR__ . '/..' . '/../lib/Settings/Admin/SystemAddressBookSettings.php', 'OCA\\DAV\\Settings\\AvailabilitySettings' => __DIR__ . '/..' . '/../lib/Settings/AvailabilitySettings.php', 'OCA\\DAV\\Settings\\CalDAVSettings' => __DIR__ . '/..' . '/../lib/Settings/CalDAVSettings.php', diff --git a/apps/dav/lib/Connector/Sabre/FilesPlugin.php b/apps/dav/lib/Connector/Sabre/FilesPlugin.php index 012b26c55a5bf..c44c6014ea4c3 100644 --- a/apps/dav/lib/Connector/Sabre/FilesPlugin.php +++ b/apps/dav/lib/Connector/Sabre/FilesPlugin.php @@ -70,6 +70,7 @@ class FilesPlugin extends ServerPlugin { public const UPLOAD_TIME_PROPERTYNAME = '{http://nextcloud.org/ns}upload_time'; public const CREATION_TIME_PROPERTYNAME = '{http://nextcloud.org/ns}creation_time'; public const LAST_ACTIVITY_PROPERTYNAME = '{http://nextcloud.org/ns}last_activity'; + public const MIME_TYPE_GROUP = '{http://nextcloud.org/ns}mime_type_group'; public const SHARE_NOTE = '{http://nextcloud.org/ns}note'; public const SHARE_HIDE_DOWNLOAD_PROPERTYNAME = '{http://nextcloud.org/ns}hide-download'; public const SUBFOLDER_COUNT_PROPERTYNAME = '{http://nextcloud.org/ns}contained-folder-count'; @@ -453,6 +454,10 @@ public function handleGetProperties(PropFind $propFind, \Sabre\DAV\INode $node) return $node->getFileInfo()->getLastActivity(); }); + $propFind->handle(self::MIME_TYPE_GROUP, function () use ($node) { + return $node instanceof GroupableFile ? $node->getGroup() : null; + }); + foreach ($node->getFileInfo()->getMetadata() as $metadataKey => $metadataValue) { $propFind->handle(self::FILE_METADATA_PREFIX . $metadataKey, fn () => $metadataValue); } diff --git a/apps/dav/lib/Connector/Sabre/GroupableFile.php b/apps/dav/lib/Connector/Sabre/GroupableFile.php new file mode 100644 index 0000000000000..07a86fbaf0022 --- /dev/null +++ b/apps/dav/lib/Connector/Sabre/GroupableFile.php @@ -0,0 +1,37 @@ +group; + } + + public function setGroup(int $group): void { + $this->group = $group; + } +} diff --git a/apps/dav/lib/Files/FileSearchBackend.php b/apps/dav/lib/Files/FileSearchBackend.php index b3b3b5508cf42..775b2769ffb57 100644 --- a/apps/dav/lib/Files/FileSearchBackend.php +++ b/apps/dav/lib/Files/FileSearchBackend.php @@ -13,12 +13,16 @@ use OC\Files\Search\SearchQuery; use OC\Files\Storage\Wrapper\Jail; use OC\Files\View; +use OCA\Files\AppInfo\Application; +use OCA\Files\ConfigLexicon; use OCA\DAV\Connector\Sabre\CachingTree; use OCA\DAV\Connector\Sabre\Directory; use OCA\DAV\Connector\Sabre\File; use OCA\DAV\Connector\Sabre\FilesPlugin; +use OCA\DAV\Connector\Sabre\GroupableFile; use OCA\DAV\Connector\Sabre\Server; use OCA\DAV\Connector\Sabre\TagsPlugin; +use OCA\DAV\Service\FileGroupingService; use OCP\Files\Cache\ICacheEntry; use OCP\Files\Folder; use OCP\Files\IRootFolder; @@ -31,6 +35,7 @@ use OCP\FilesMetadata\IFilesMetadataManager; use OCP\FilesMetadata\IMetadataQuery; use OCP\FilesMetadata\Model\IMetadataValueWrapper; +use OCP\IAppConfig; use OCP\IUser; use OCP\Share\IManager; use Sabre\DAV\Exception\NotFound; @@ -54,6 +59,8 @@ public function __construct( private IManager $shareManager, private View $view, private IFilesMetadataManager $filesMetadataManager, + private FileGroupingService $fileGroupingService, + private IAppConfig $appConfig, ) { } @@ -93,6 +100,7 @@ public function getPropertyDefinitionsForScope(string $href, ?string $path): arr new SearchPropertyDefinition('{DAV:}creationdate', true, true, true, SearchPropertyDefinition::DATATYPE_DATETIME), new SearchPropertyDefinition('{http://nextcloud.org/ns}upload_time', true, true, true, SearchPropertyDefinition::DATATYPE_DATETIME), new SearchPropertyDefinition('{http://nextcloud.org/ns}last_activity', true, false, true, SearchPropertyDefinition::DATATYPE_DATETIME), + new SearchPropertyDefinition(FilesPlugin::MIME_TYPE_GROUP, true, false, false, SearchPropertyDefinition::DATATYPE_NONNEGATIVE_INTEGER), new SearchPropertyDefinition(FilesPlugin::SIZE_PROPERTYNAME, true, true, true, SearchPropertyDefinition::DATATYPE_NONNEGATIVE_INTEGER), new SearchPropertyDefinition(TagsPlugin::FAVORITE_PROPERTYNAME, true, true, true, SearchPropertyDefinition::DATATYPE_BOOLEAN), new SearchPropertyDefinition(FilesPlugin::INTERNAL_FILEID_PROPERTYNAME, true, true, false, SearchPropertyDefinition::DATATYPE_NONNEGATIVE_INTEGER), @@ -175,9 +183,9 @@ public function search(Query $search): array { break; case 1: $scope = $search->from[0]; - $folder = $this->getFolderForPath($scope->path); + $searchTarget = $this->getFolderForPath($scope->path); $query = $this->transformQuery($search); - $results = $folder->search($query); + $results = $searchTarget->search($query); break; default: $scopes = []; @@ -212,23 +220,25 @@ public function search(Query $search): array { $scopeOperators = new SearchBinaryOperator(ISearchBinaryOperator::OPERATOR_OR, $scopes); $query = $this->transformQuery($search, $scopeOperators); - $userFolder = $this->rootFolder->getUserFolder($this->user->getUID()); - $results = $userFolder->search($query); + $searchTarget = $this->rootFolder->getUserFolder($this->user->getUID()); + $results = $searchTarget->search($query); } + $groupRecentFilesEnabled = $this->appConfig->getValueBool(Application::APP_ID, ConfigLexicon::GROUP_RECENT_FILES, false); + $mimeTypeGroupRequested = false; + if ($groupRecentFilesEnabled) { + $mimeTypeGroupRequested = $this->isPropertyRequested($search, FilesPlugin::MIME_TYPE_GROUP); + } + $shouldGroupFiles = $groupRecentFilesEnabled && $mimeTypeGroupRequested; + /** @var SearchResult[] $nodes */ - $nodes = array_map(function (Node $node) { - if ($node instanceof Folder) { - $davNode = new Directory($this->view, $node, $this->tree, $this->shareManager); - } else { - $davNode = new File($this->view, $node, $this->shareManager); - } - $path = $this->getHrefForNode($node); - $this->tree->cacheNode($davNode, $path); - return new SearchResult($davNode, $path); - }, $results); + $nodes = $this->mapNodesToSearchResults($results, $shouldGroupFiles); + + if ($shouldGroupFiles) { + $nodes = $this->groupNodesAndFetchMoreIfNeeded($search, $query, $results, $nodes, $searchTarget); + } - if (!$query->limitToHome()) { + if (!$query->limitToHome() && !$shouldGroupFiles) { // Sort again, since the result from multiple storages is appended and not sorted usort($nodes, function (SearchResult $a, SearchResult $b) use ($search) { return $this->sort($a, $b, $search->orderBy); @@ -236,13 +246,32 @@ public function search(Query $search): array { } // If a limit is provided use only return that number of files - if ($search->limit->maxResults !== 0) { + if ($search->limit->maxResults !== 0 && !$shouldGroupFiles) { $nodes = \array_slice($nodes, 0, $search->limit->maxResults); } return $nodes; } + /** + * @param Node[] $nodes + * @return SearchResult[] + */ + private function mapNodesToSearchResults(array $nodes, bool $shouldGroupFiles): array { + return array_map(function (Node $node) use ($shouldGroupFiles) { + if ($node instanceof Folder) { + $davNode = new Directory($this->view, $node, $this->tree, $this->shareManager); + } elseif ($shouldGroupFiles) { + $davNode = new GroupableFile($this->view, $node, $this->shareManager); + } else { + $davNode = new File($this->view, $node, $this->shareManager); + } + $path = $this->getHrefForNode($node); + $this->tree->cacheNode($davNode, $path); + return new SearchResult($davNode, $path); + }, $nodes); + } + private function sort(SearchResult $a, SearchResult $b, array $orders) { /** @var Order $order */ foreach ($orders as $order) { @@ -572,4 +601,82 @@ private function extractWhereValue(Operator &$operator, string $propertyName, st return null; } } + + private function isPropertyRequested(Query $search, string $propertyName): bool { + foreach ($search->select as $property) { + if ($property->name === $propertyName) { + return true; + } + } + return false; + } + + /** + * @param Node[] $results + * @param SearchResult[] $nodes + * @return SearchResult[] + */ + private function groupNodesAndFetchMoreIfNeeded(Query $search, ISearchQuery $query, array $results, array $nodes, Folder $searchTarget): array { + $mimeTypes = $this->appConfig->getValueArray(Application::APP_ID, ConfigLexicon::RECENT_FILES_GROUP_MIME_TYPES, []); + $minGroupSize = $this->appConfig->getValueInt(Application::APP_ID, ConfigLexicon::RECENT_FILES_GROUP_MIN_GROUP_SIZE, 2); + $timespanMinutes = $this->appConfig->getValueInt(Application::APP_ID, ConfigLexicon::RECENT_FILES_GROUP_TIMESPAN_MINUTES, 2); + $collapsedItemsLimit = $this->appConfig->getValueInt(Application::APP_ID, ConfigLexicon::RECENT_FILES_GROUP_COLLAPSED_ITEMS_LIMIT, 25); + + [$nodes, $collapsedCount] = $this->fileGroupingService->setGroupOnNodes($nodes, $mimeTypes, $minGroupSize, $timespanMinutes); + + $queryLimit = $query->getLimit(); + $queryOffset = $query->getOffset(); + $maxExtraFetches = 5; + + for ($i = 0; $i < $maxExtraFetches; $i++) { + $lastNode = $nodes[array_key_last($nodes)] ?? null; + $lastGroupMightContinue = $lastNode !== null && $this->fileGroupingService->isNodeGroupable($lastNode, $mimeTypes); + + $needsMore = count($results) === $queryLimit && ($collapsedCount < $collapsedItemsLimit || $lastGroupMightContinue); + if (!$needsMore) { + break; + } + + $queryOffset += $queryLimit; + $search->limit->firstResult = $queryOffset; + $query = $this->transformQuery($search); + $results = $searchTarget->search($query); + + if (empty($results)) { + break; + } + + $extraNodes = $this->mapNodesToSearchResults($results, true); + $nodes = array_merge($nodes, $extraNodes); + [$nodes, $collapsedCount] = $this->fileGroupingService->setGroupOnNodes($nodes, $mimeTypes, $minGroupSize, $timespanMinutes); + } + + return $this->sliceGroupableFilesSearchResult($nodes, $collapsedItemsLimit); + } + + private function sliceGroupableFilesSearchResult(array $nodes, int $limit): array { + $result = []; + $count = 0; + $seenGroups = []; + + foreach ($nodes as $searchResult) { + $node = $searchResult->node; + $group = ($node instanceof GroupableFile) ? $node->getGroup() : null; + + if ($group === null) { + $count++; + } elseif (!isset($seenGroups[$group])) { + $seenGroups[$group] = true; + $count++; + } + + if ($count > $limit) { + return $result; + } + + $result[] = $searchResult; + } + + return $result; + } } diff --git a/apps/dav/lib/Server.php b/apps/dav/lib/Server.php index 68068dcfb6302..159ffe026fd1a 100644 --- a/apps/dav/lib/Server.php +++ b/apps/dav/lib/Server.php @@ -72,6 +72,7 @@ use OCA\DAV\Upload\ChunkingPlugin; use OCA\DAV\Upload\ChunkingV2Plugin; use OCA\DAV\Upload\UploadAutoMkcolPlugin; +use OCA\DAV\Service\FileGroupingService; use OCA\Theming\ThemingDefaults; use OCP\Accounts\IAccountManager; use OCP\App\IAppManager; @@ -381,7 +382,9 @@ public function __construct( \OCP\Server::get(IRootFolder::class), $shareManager, $view, - \OCP\Server::get(IFilesMetadataManager::class) + \OCP\Server::get(IFilesMetadataManager::class), + \OCP\Server::get(FileGroupingService::class), + \OCP\Server::get(IAppConfig::class), )); $this->server->addPlugin( new BulkUploadPlugin( diff --git a/apps/dav/lib/Service/FileGroupingService.php b/apps/dav/lib/Service/FileGroupingService.php new file mode 100644 index 0000000000000..0685e9bfb6fd2 --- /dev/null +++ b/apps/dav/lib/Service/FileGroupingService.php @@ -0,0 +1,117 @@ +isNodeGroupable($current, $mimeTypes)) { + $result[] = $current; + $i++; + $colapsedCount++; + continue; + } + + $currentTime = $this->getNodeTime($current); + $isContaminated = false; + + // check if the time window is contaminated by a non-groupable node + for ($j = $i + 1; $j < $count; $j++) { + $nextTime = $this->getNodeTime($searchResults[$j]); + if (abs($currentTime - $nextTime) > $timespan) { + break; + } + if (!$this->isNodeGroupable($searchResults[$j], $mimeTypes)) { + $isContaminated = true; + break; + } + } + + if ($isContaminated) { + $result[] = $current; + $i++; + $colapsedCount++; + continue; + } + + $groupIndexes = [$i]; + $i++; + + // add nodes to group until time window limit is reached + while ($i < $count) { + $next = $searchResults[$i]; + $nextTime = $this->getNodeTime($next); + + if (abs($currentTime - $nextTime) > $timespan) { + break; + } + + $groupIndexes[] = $i; + $i++; + } + + if (count($groupIndexes) < $minGroupSize) { + foreach ($groupIndexes as $idx) { + $result[] = $searchResults[$idx]; + $colapsedCount++; + } + continue; + } + + foreach ($groupIndexes as $idx) { + /** @var GroupableFile $node */ + $node = $searchResults[$idx]->node; + $node->setGroup($groupNumber); + $result[] = $searchResults[$idx]; + } + $groupNumber++; + $colapsedCount++; + } + + return [$result, $colapsedCount]; + } + + public function isNodeGroupable(SearchResult $result, array $mimeTypes): bool { + $node = $result->node; + return $node instanceof GroupableFile && in_array($node->getNode()->getMimetype(), $mimeTypes, true); + } + + private function getNodeTime(SearchResult $result): int { + $node = $result->node; + if (!$node instanceof GroupableFile) { + return 0; + } + $uploadTime = $node->getNode()->getUploadTime(); + $creationTime = $node->getNode()->getCreationTime(); + $lastModified = $node->getLastModified(); + return max($uploadTime, $creationTime, $lastModified); + } +} diff --git a/apps/dav/tests/unit/Files/FileSearchBackendTest.php b/apps/dav/tests/unit/Files/FileSearchBackendTest.php index 5d57ca4bd035e..d342fcd4aec3e 100644 --- a/apps/dav/tests/unit/Files/FileSearchBackendTest.php +++ b/apps/dav/tests/unit/Files/FileSearchBackendTest.php @@ -17,6 +17,8 @@ use OCA\DAV\Connector\Sabre\ObjectTree; use OCA\DAV\Connector\Sabre\Server; use OCA\DAV\Files\FileSearchBackend; +use OCA\DAV\Service\FileGroupingService; +use OCP\IAppConfig; use OCP\Files\FileInfo; use OCP\Files\Folder; use OCP\Files\IRootFolder; @@ -80,8 +82,10 @@ protected function setUp(): void { ->willReturn($this->searchFolder); $filesMetadataManager = $this->createMock(IFilesMetadataManager::class); + $appConfig = $this->createMock(IAppConfig::class); + $fileGroupingService = $this->createMock(FileGroupingService::class); - $this->search = new FileSearchBackend($this->server, $this->tree, $this->user, $this->rootFolder, $this->shareManager, $this->view, $filesMetadataManager); + $this->search = new FileSearchBackend($this->server, $this->tree, $this->user, $this->rootFolder, $this->shareManager, $this->view, $filesMetadataManager, $fileGroupingService, $appConfig); } public function testSearchFilename(): void { diff --git a/apps/files/lib/ConfigLexicon.php b/apps/files/lib/ConfigLexicon.php index 3d3c59886858c..4091d44926766 100644 --- a/apps/files/lib/ConfigLexicon.php +++ b/apps/files/lib/ConfigLexicon.php @@ -25,7 +25,9 @@ class ConfigLexicon implements ILexicon { public const RECENT_LIMIT = 'recent_limit'; public const GROUP_RECENT_FILES = 'group_recent_files'; public const RECENT_FILES_GROUP_MIME_TYPES = 'recent_files_group_mime_types'; + public const RECENT_FILES_GROUP_MIN_GROUP_SIZE = 'recent_files_group_min_group_size'; public const RECENT_FILES_GROUP_TIMESPAN_MINUTES = 'recent_files_group_timespan_minutes'; + public const RECENT_FILES_GROUP_COLLAPSED_ITEMS_LIMIT = 'recent_files_group_collapsed_items_limit'; public const LOCAL_CLIENT_INTEGRATION = 'local_client_integration'; #[\Override] @@ -65,6 +67,13 @@ public function getAppConfigs(): array { definition: 'Which MIME types to group in the recent files list', lazy: false, ), + new Entry( + self::RECENT_FILES_GROUP_MIN_GROUP_SIZE, + ValueType::INT, + defaultRaw: 2, + definition: 'Minimum number of files needed to form a group in the recent files list', + lazy: false, + ), new Entry( self::RECENT_FILES_GROUP_TIMESPAN_MINUTES, ValueType::INT, @@ -72,6 +81,13 @@ public function getAppConfigs(): array { definition: 'Time window in minutes to group files uploaded close together in the recent files list', lazy: false, ), + new Entry( + self::RECENT_FILES_GROUP_COLLAPSED_ITEMS_LIMIT, + ValueType::INT, + defaultRaw: 25, + definition: 'Maximum number of items (collapsed) to display on recent files view', + lazy: false, + ), new Entry( self::LOCAL_CLIENT_INTEGRATION, ValueType::BOOL,