From 512f18e061b828d01e1fe7422ea280b27e29f720 Mon Sep 17 00:00:00 2001 From: Cristian Scheid Date: Wed, 10 Jun 2026 17:35:23 +0200 Subject: [PATCH] feat(recent-files): allow grouping search results by mime type Signed-off-by: Cristian Scheid --- .../composer/composer/autoload_classmap.php | 1 + .../dav/composer/composer/autoload_static.php | 1 + apps/dav/lib/Connector/Sabre/FilesPlugin.php | 5 + .../dav/lib/Connector/Sabre/GroupableFile.php | 39 ++++++ apps/dav/lib/Files/FileSearchBackend.php | 115 +++++++++++++++++- apps/dav/lib/Server.php | 3 +- .../unit/Files/FileSearchBackendTest.php | 4 +- 7 files changed, 165 insertions(+), 3 deletions(-) create mode 100644 apps/dav/lib/Connector/Sabre/GroupableFile.php diff --git a/apps/dav/composer/composer/autoload_classmap.php b/apps/dav/composer/composer/autoload_classmap.php index 2ca5cf66f901f..6888be6be1590 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', diff --git a/apps/dav/composer/composer/autoload_static.php b/apps/dav/composer/composer/autoload_static.php index c35dd97c02c0e..dd5fe2a8f0bf0 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', diff --git a/apps/dav/lib/Connector/Sabre/FilesPlugin.php b/apps/dav/lib/Connector/Sabre/FilesPlugin.php index e04a4d361f7b3..af066178eac8c 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() : 0; + }); + 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..4e9d8b8d6bf06 --- /dev/null +++ b/apps/dav/lib/Connector/Sabre/GroupableFile.php @@ -0,0 +1,39 @@ +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..00cb9663b186e 100644 --- a/apps/dav/lib/Files/FileSearchBackend.php +++ b/apps/dav/lib/Files/FileSearchBackend.php @@ -13,10 +13,13 @@ 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 OCP\Files\Cache\ICacheEntry; @@ -31,6 +34,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 +58,7 @@ public function __construct( private IManager $shareManager, private View $view, private IFilesMetadataManager $filesMetadataManager, + private IAppConfig $appConfig, ) { } @@ -216,10 +221,14 @@ public function search(Query $search): array { $results = $userFolder->search($query); } + $groupRecentFilesEnabled = $this->appConfig->getValueBool(Application::APP_ID, ConfigLexicon::GROUP_RECENT_FILES, false); + /** @var SearchResult[] $nodes */ - $nodes = array_map(function (Node $node) { + $nodes = array_map(function (Node $node) use ($groupRecentFilesEnabled) { if ($node instanceof Folder) { $davNode = new Directory($this->view, $node, $this->tree, $this->shareManager); + } elseif ($groupRecentFilesEnabled) { + $davNode = new GroupableFile($this->view, $node, $this->shareManager); } else { $davNode = new File($this->view, $node, $this->shareManager); } @@ -228,6 +237,10 @@ public function search(Query $search): array { return new SearchResult($davNode, $path); }, $results); + if ($groupRecentFilesEnabled) { + $nodes = $this->setGroupOnNodes($nodes); + } + if (!$query->limitToHome()) { // Sort again, since the result from multiple storages is appended and not sorted usort($nodes, function (SearchResult $a, SearchResult $b) use ($search) { @@ -572,4 +585,104 @@ private function extractWhereValue(Operator &$operator, string $propertyName, st return null; } } + + /** + * @param SearchResult[] $searchResults + * @return SearchResult[] $searchResults + */ + private function setGroupOnNodes(array $searchResults): array + { + $mimeTypes = $this->appConfig->getValueArray(Application::APP_ID, ConfigLexicon::RECENT_FILES_GROUP_MIME_TYPES, []); + if (count($mimeTypes) === 0) { + return $searchResults; + } + $timespanMinutes = $this->appConfig->getValueInt(Application::APP_ID, ConfigLexicon::RECENT_FILES_GROUP_TIMESPAN_MINUTES, 2); + $timespan = $timespanMinutes * 60; + + // sort by most most recent action to the oldest + usort($searchResults, fn($a, $b) => $this->getNodeTime($b) <=> $this->getNodeTime($a)); + + $count = count($searchResults); + $result = []; + $groupNumber = 1; + $i = 0; + + while ($i < $count) { + $current = $searchResults[$i]; + + if (!$this->isNodeGroupable($current, $mimeTypes)) { + $result[] = $current; + $i++; + continue; + } + + $groupStartTime = $this->getNodeTime($current); + $isContaminated = false; + + // look ahead to 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($nextTime - $groupStartTime) > $timespan) { + break; + } + if (!$this->isNodeGroupable($searchResults[$j], $mimeTypes)) { + $isContaminated = true; + break; + } + } + + if ($isContaminated) { + $result[] = $current; + $i++; + 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($nextTime - $groupStartTime) > $timespan) { + break; + } + + $groupIndexes[] = $i; + $i++; + } + + if (count($groupIndexes) === 1) { + $result[] = $searchResults[$groupIndexes[0]]; + continue; + } + + foreach ($groupIndexes as $idx) { + $searchResults[$idx]->node->setGroup($groupNumber); + $result[] = $searchResults[$idx]; + } + $groupNumber++; + } + + return $result; + } + + private function getNodeTime(SearchResult $result): int + { + $node = $result->node; + if (!$node instanceof GroupableFile) { + return 0; + } + $uploadTime = $node->getNode()->getUploadTime() ?? 0; + $crtime = $node->getNode()->getCreationTime() ?? 0; + $mtime = $node->getLastModified() ?? 0; + return max((int)$uploadTime, (int)$crtime, (int)$mtime); + } + + private function isNodeGroupable(SearchResult $result, array $mimeTypes): bool + { + $node = $result->node; + return $node instanceof GroupableFile && in_array($node->getNode()->getMimetype(), $mimeTypes, true); + } } diff --git a/apps/dav/lib/Server.php b/apps/dav/lib/Server.php index ea4350bc1529d..aa53c24405092 100644 --- a/apps/dav/lib/Server.php +++ b/apps/dav/lib/Server.php @@ -379,7 +379,8 @@ public function __construct( \OCP\Server::get(IRootFolder::class), $shareManager, $view, - \OCP\Server::get(IFilesMetadataManager::class) + \OCP\Server::get(IFilesMetadataManager::class), + \OCP\Server::get(IAppConfig::class), )); $this->server->addPlugin( new BulkUploadPlugin( diff --git a/apps/dav/tests/unit/Files/FileSearchBackendTest.php b/apps/dav/tests/unit/Files/FileSearchBackendTest.php index 5d57ca4bd035e..3ed1031391e2b 100644 --- a/apps/dav/tests/unit/Files/FileSearchBackendTest.php +++ b/apps/dav/tests/unit/Files/FileSearchBackendTest.php @@ -17,6 +17,7 @@ use OCA\DAV\Connector\Sabre\ObjectTree; use OCA\DAV\Connector\Sabre\Server; use OCA\DAV\Files\FileSearchBackend; +use OCP\IAppConfig; use OCP\Files\FileInfo; use OCP\Files\Folder; use OCP\Files\IRootFolder; @@ -80,8 +81,9 @@ protected function setUp(): void { ->willReturn($this->searchFolder); $filesMetadataManager = $this->createMock(IFilesMetadataManager::class); + $appConfig = $this->createMock(IAppConfig::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, $appConfig); } public function testSearchFilename(): void {