Skip to content

Commit 5d82cff

Browse files
feat(recent-files): allow grouping search results by mime type
Signed-off-by: Cristian Scheid <cristianscheid@gmail.com>
1 parent 82b2ab2 commit 5d82cff

9 files changed

Lines changed: 338 additions & 18 deletions

File tree

apps/dav/composer/composer/autoload_classmap.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,7 @@
239239
'OCA\\DAV\\Connector\\Sabre\\File' => $baseDir . '/../lib/Connector/Sabre/File.php',
240240
'OCA\\DAV\\Connector\\Sabre\\FilesPlugin' => $baseDir . '/../lib/Connector/Sabre/FilesPlugin.php',
241241
'OCA\\DAV\\Connector\\Sabre\\FilesReportPlugin' => $baseDir . '/../lib/Connector/Sabre/FilesReportPlugin.php',
242+
'OCA\\DAV\\Connector\\Sabre\\GroupableFile' => $baseDir . '/../lib/Connector/Sabre/GroupableFile.php',
242243
'OCA\\DAV\\Connector\\Sabre\\LockPlugin' => $baseDir . '/../lib/Connector/Sabre/LockPlugin.php',
243244
'OCA\\DAV\\Connector\\Sabre\\MaintenancePlugin' => $baseDir . '/../lib/Connector/Sabre/MaintenancePlugin.php',
244245
'OCA\\DAV\\Connector\\Sabre\\MtimeSanitizer' => $baseDir . '/../lib/Connector/Sabre/MtimeSanitizer.php',
@@ -416,6 +417,7 @@
416417
'OCA\\DAV\\Service\\AbsenceService' => $baseDir . '/../lib/Service/AbsenceService.php',
417418
'OCA\\DAV\\Service\\ExampleContactService' => $baseDir . '/../lib/Service/ExampleContactService.php',
418419
'OCA\\DAV\\Service\\ExampleEventService' => $baseDir . '/../lib/Service/ExampleEventService.php',
420+
'OCA\\DAV\\Service\\FileGroupingService' => $baseDir . '/../lib/Service/FileGroupingService.php',
419421
'OCA\\DAV\\Settings\\Admin\\SystemAddressBookSettings' => $baseDir . '/../lib/Settings/Admin/SystemAddressBookSettings.php',
420422
'OCA\\DAV\\Settings\\AvailabilitySettings' => $baseDir . '/../lib/Settings/AvailabilitySettings.php',
421423
'OCA\\DAV\\Settings\\CalDAVSettings' => $baseDir . '/../lib/Settings/CalDAVSettings.php',

apps/dav/composer/composer/autoload_static.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,7 @@ class ComposerStaticInitDAV
254254
'OCA\\DAV\\Connector\\Sabre\\File' => __DIR__ . '/..' . '/../lib/Connector/Sabre/File.php',
255255
'OCA\\DAV\\Connector\\Sabre\\FilesPlugin' => __DIR__ . '/..' . '/../lib/Connector/Sabre/FilesPlugin.php',
256256
'OCA\\DAV\\Connector\\Sabre\\FilesReportPlugin' => __DIR__ . '/..' . '/../lib/Connector/Sabre/FilesReportPlugin.php',
257+
'OCA\\DAV\\Connector\\Sabre\\GroupableFile' => __DIR__ . '/..' . '/../lib/Connector/Sabre/GroupableFile.php',
257258
'OCA\\DAV\\Connector\\Sabre\\LockPlugin' => __DIR__ . '/..' . '/../lib/Connector/Sabre/LockPlugin.php',
258259
'OCA\\DAV\\Connector\\Sabre\\MaintenancePlugin' => __DIR__ . '/..' . '/../lib/Connector/Sabre/MaintenancePlugin.php',
259260
'OCA\\DAV\\Connector\\Sabre\\MtimeSanitizer' => __DIR__ . '/..' . '/../lib/Connector/Sabre/MtimeSanitizer.php',
@@ -431,6 +432,7 @@ class ComposerStaticInitDAV
431432
'OCA\\DAV\\Service\\AbsenceService' => __DIR__ . '/..' . '/../lib/Service/AbsenceService.php',
432433
'OCA\\DAV\\Service\\ExampleContactService' => __DIR__ . '/..' . '/../lib/Service/ExampleContactService.php',
433434
'OCA\\DAV\\Service\\ExampleEventService' => __DIR__ . '/..' . '/../lib/Service/ExampleEventService.php',
435+
'OCA\\DAV\\Service\\FileGroupingService' => __DIR__ . '/..' . '/../lib/Service/FileGroupingService.php',
434436
'OCA\\DAV\\Settings\\Admin\\SystemAddressBookSettings' => __DIR__ . '/..' . '/../lib/Settings/Admin/SystemAddressBookSettings.php',
435437
'OCA\\DAV\\Settings\\AvailabilitySettings' => __DIR__ . '/..' . '/../lib/Settings/AvailabilitySettings.php',
436438
'OCA\\DAV\\Settings\\CalDAVSettings' => __DIR__ . '/..' . '/../lib/Settings/CalDAVSettings.php',

apps/dav/lib/Connector/Sabre/FilesPlugin.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ class FilesPlugin extends ServerPlugin {
7070
public const UPLOAD_TIME_PROPERTYNAME = '{http://nextcloud.org/ns}upload_time';
7171
public const CREATION_TIME_PROPERTYNAME = '{http://nextcloud.org/ns}creation_time';
7272
public const LAST_ACTIVITY_PROPERTYNAME = '{http://nextcloud.org/ns}last_activity';
73+
public const MIME_TYPE_GROUP = '{http://nextcloud.org/ns}mime_type_group';
7374
public const SHARE_NOTE = '{http://nextcloud.org/ns}note';
7475
public const SHARE_HIDE_DOWNLOAD_PROPERTYNAME = '{http://nextcloud.org/ns}hide-download';
7576
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)
453454
return $node->getFileInfo()->getLastActivity();
454455
});
455456

457+
$propFind->handle(self::MIME_TYPE_GROUP, function () use ($node) {
458+
return $node instanceof GroupableFile ? $node->getGroup() : null;
459+
});
460+
456461
foreach ($node->getFileInfo()->getMetadata() as $metadataKey => $metadataValue) {
457462
$propFind->handle(self::FILE_METADATA_PREFIX . $metadataKey, fn () => $metadataValue);
458463
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<?php
2+
3+
/**
4+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
5+
* SPDX-License-Identifier: AGPL-3.0-only
6+
*/
7+
8+
namespace OCA\DAV\Connector\Sabre;
9+
10+
use OC\Files\View;
11+
use OCP\Files\FileInfo;
12+
use OCP\IL10N;
13+
use OCP\IRequest;
14+
use OCP\Share\IManager;
15+
16+
class GroupableFile extends File {
17+
18+
public function __construct(
19+
View $view,
20+
FileInfo $info,
21+
?IManager $shareManager = null,
22+
?IRequest $request = null,
23+
?IL10N $l10n = null,
24+
protected ?int $group = null,
25+
) {
26+
parent::__construct($view, $info, $shareManager, $request, $l10n);
27+
}
28+
29+
public function getGroup(): ?int {
30+
return $this->group;
31+
}
32+
33+
public function setGroup(int $group): void {
34+
$this->group = $group;
35+
}
36+
}

apps/dav/lib/Files/FileSearchBackend.php

Lines changed: 124 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,12 @@
1717
use OCA\DAV\Connector\Sabre\Directory;
1818
use OCA\DAV\Connector\Sabre\File;
1919
use OCA\DAV\Connector\Sabre\FilesPlugin;
20+
use OCA\DAV\Connector\Sabre\GroupableFile;
2021
use OCA\DAV\Connector\Sabre\Server;
2122
use OCA\DAV\Connector\Sabre\TagsPlugin;
23+
use OCA\DAV\Service\FileGroupingService;
24+
use OCA\Files\AppInfo\Application;
25+
use OCA\Files\ConfigLexicon;
2226
use OCP\Files\Cache\ICacheEntry;
2327
use OCP\Files\Folder;
2428
use OCP\Files\IRootFolder;
@@ -31,6 +35,7 @@
3135
use OCP\FilesMetadata\IFilesMetadataManager;
3236
use OCP\FilesMetadata\IMetadataQuery;
3337
use OCP\FilesMetadata\Model\IMetadataValueWrapper;
38+
use OCP\IAppConfig;
3439
use OCP\IUser;
3540
use OCP\Share\IManager;
3641
use Sabre\DAV\Exception\NotFound;
@@ -54,6 +59,8 @@ public function __construct(
5459
private IManager $shareManager,
5560
private View $view,
5661
private IFilesMetadataManager $filesMetadataManager,
62+
private FileGroupingService $fileGroupingService,
63+
private IAppConfig $appConfig,
5764
) {
5865
}
5966

@@ -93,6 +100,7 @@ public function getPropertyDefinitionsForScope(string $href, ?string $path): arr
93100
new SearchPropertyDefinition('{DAV:}creationdate', true, true, true, SearchPropertyDefinition::DATATYPE_DATETIME),
94101
new SearchPropertyDefinition('{http://nextcloud.org/ns}upload_time', true, true, true, SearchPropertyDefinition::DATATYPE_DATETIME),
95102
new SearchPropertyDefinition('{http://nextcloud.org/ns}last_activity', true, false, true, SearchPropertyDefinition::DATATYPE_DATETIME),
103+
new SearchPropertyDefinition(FilesPlugin::MIME_TYPE_GROUP, true, false, false, SearchPropertyDefinition::DATATYPE_NONNEGATIVE_INTEGER),
96104
new SearchPropertyDefinition(FilesPlugin::SIZE_PROPERTYNAME, true, true, true, SearchPropertyDefinition::DATATYPE_NONNEGATIVE_INTEGER),
97105
new SearchPropertyDefinition(TagsPlugin::FAVORITE_PROPERTYNAME, true, true, true, SearchPropertyDefinition::DATATYPE_BOOLEAN),
98106
new SearchPropertyDefinition(FilesPlugin::INTERNAL_FILEID_PROPERTYNAME, true, true, false, SearchPropertyDefinition::DATATYPE_NONNEGATIVE_INTEGER),
@@ -175,9 +183,9 @@ public function search(Query $search): array {
175183
break;
176184
case 1:
177185
$scope = $search->from[0];
178-
$folder = $this->getFolderForPath($scope->path);
186+
$searchTarget = $this->getFolderForPath($scope->path);
179187
$query = $this->transformQuery($search);
180-
$results = $folder->search($query);
188+
$results = $searchTarget->search($query);
181189
break;
182190
default:
183191
$scopes = [];
@@ -212,37 +220,58 @@ public function search(Query $search): array {
212220

213221
$scopeOperators = new SearchBinaryOperator(ISearchBinaryOperator::OPERATOR_OR, $scopes);
214222
$query = $this->transformQuery($search, $scopeOperators);
215-
$userFolder = $this->rootFolder->getUserFolder($this->user->getUID());
216-
$results = $userFolder->search($query);
223+
$searchTarget = $this->rootFolder->getUserFolder($this->user->getUID());
224+
$results = $searchTarget->search($query);
217225
}
218226

227+
$groupRecentFilesEnabled = $this->appConfig->getValueBool(Application::APP_ID, ConfigLexicon::GROUP_RECENT_FILES, false);
228+
$mimeTypeGroupRequested = false;
229+
if ($groupRecentFilesEnabled) {
230+
$mimeTypeGroupRequested = $this->isPropertyRequested($search, FilesPlugin::MIME_TYPE_GROUP);
231+
}
232+
$shouldGroupFiles = $groupRecentFilesEnabled && $mimeTypeGroupRequested;
233+
219234
/** @var SearchResult[] $nodes */
220-
$nodes = array_map(function (Node $node) {
221-
if ($node instanceof Folder) {
222-
$davNode = new Directory($this->view, $node, $this->tree, $this->shareManager);
223-
} else {
224-
$davNode = new File($this->view, $node, $this->shareManager);
225-
}
226-
$path = $this->getHrefForNode($node);
227-
$this->tree->cacheNode($davNode, $path);
228-
return new SearchResult($davNode, $path);
229-
}, $results);
235+
$nodes = $this->mapNodesToSearchResults($results, $shouldGroupFiles);
236+
237+
if ($shouldGroupFiles) {
238+
$nodes = $this->groupNodesAndFetchMoreIfNeeded($search, $query, $results, $nodes, $searchTarget);
239+
}
230240

231-
if (!$query->limitToHome()) {
241+
if (!$query->limitToHome() && !$shouldGroupFiles) {
232242
// Sort again, since the result from multiple storages is appended and not sorted
233243
usort($nodes, function (SearchResult $a, SearchResult $b) use ($search) {
234244
return $this->sort($a, $b, $search->orderBy);
235245
});
236246
}
237247

238248
// If a limit is provided use only return that number of files
239-
if ($search->limit->maxResults !== 0) {
249+
if ($search->limit->maxResults !== 0 && !$shouldGroupFiles) {
240250
$nodes = \array_slice($nodes, 0, $search->limit->maxResults);
241251
}
242252

243253
return $nodes;
244254
}
245255

256+
/**
257+
* @param Node[] $nodes
258+
* @return SearchResult[]
259+
*/
260+
private function mapNodesToSearchResults(array $nodes, bool $shouldGroupFiles): array {
261+
return array_map(function (Node $node) use ($shouldGroupFiles) {
262+
if ($node instanceof Folder) {
263+
$davNode = new Directory($this->view, $node, $this->tree, $this->shareManager);
264+
} elseif ($shouldGroupFiles) {
265+
$davNode = new GroupableFile($this->view, $node, $this->shareManager);
266+
} else {
267+
$davNode = new File($this->view, $node, $this->shareManager);
268+
}
269+
$path = $this->getHrefForNode($node);
270+
$this->tree->cacheNode($davNode, $path);
271+
return new SearchResult($davNode, $path);
272+
}, $nodes);
273+
}
274+
246275
private function sort(SearchResult $a, SearchResult $b, array $orders) {
247276
/** @var Order $order */
248277
foreach ($orders as $order) {
@@ -572,4 +601,83 @@ private function extractWhereValue(Operator &$operator, string $propertyName, st
572601
return null;
573602
}
574603
}
604+
605+
private function isPropertyRequested(Query $search, string $propertyName): bool {
606+
foreach ($search->select as $property) {
607+
if ($property->name === $propertyName) {
608+
return true;
609+
}
610+
}
611+
return false;
612+
}
613+
614+
/**
615+
* @param Node[] $results
616+
* @param SearchResult[] $nodes
617+
* @return SearchResult[]
618+
*/
619+
private function groupNodesAndFetchMoreIfNeeded(Query $search, ISearchQuery $query, array $results, array $nodes, Folder $searchTarget): array {
620+
$mimeTypes = $this->appConfig->getValueArray(Application::APP_ID, ConfigLexicon::RECENT_FILES_GROUP_MIME_TYPES, []);
621+
$sameFolderOnly = $this->appConfig->getValueBool(Application::APP_ID, ConfigLexicon::RECENT_FILES_GROUP_SAME_FOLDER_ONLY, true);
622+
$minGroupSize = $this->appConfig->getValueInt(Application::APP_ID, ConfigLexicon::RECENT_FILES_GROUP_MIN_GROUP_SIZE, 2);
623+
$timespanMinutes = $this->appConfig->getValueInt(Application::APP_ID, ConfigLexicon::RECENT_FILES_GROUP_TIMESPAN_MINUTES, 2);
624+
$collapsedItemsLimit = $this->appConfig->getValueInt(Application::APP_ID, ConfigLexicon::RECENT_FILES_GROUP_COLLAPSED_ITEMS_LIMIT, 25);
625+
626+
[$nodes, $collapsedCount] = $this->fileGroupingService->setGroupOnNodes($nodes, $mimeTypes, $sameFolderOnly, $minGroupSize, $timespanMinutes);
627+
628+
$queryLimit = $query->getLimit();
629+
$queryOffset = $query->getOffset();
630+
$maxExtraFetches = 5;
631+
632+
for ($i = 0; $i < $maxExtraFetches; $i++) {
633+
$lastNode = $nodes[array_key_last($nodes)] ?? null;
634+
$lastGroupMightContinue = $lastNode !== null && $this->fileGroupingService->isNodeGroupable($lastNode, $mimeTypes);
635+
636+
$needsMore = count($results) === $queryLimit && ($collapsedCount < $collapsedItemsLimit || $lastGroupMightContinue);
637+
if (!$needsMore) {
638+
break;
639+
}
640+
641+
$queryOffset += $queryLimit;
642+
$search->limit->firstResult = $queryOffset;
643+
$query = $this->transformQuery($search);
644+
$results = $searchTarget->search($query);
645+
646+
if (empty($results)) {
647+
break;
648+
}
649+
650+
$extraNodes = $this->mapNodesToSearchResults($results, true);
651+
$nodes = array_merge($nodes, $extraNodes);
652+
[$nodes, $collapsedCount] = $this->fileGroupingService->setGroupOnNodes($nodes, $mimeTypes, $sameFolderOnly, $minGroupSize, $timespanMinutes);
653+
}
654+
655+
return $this->sliceGroupableFilesSearchResult($nodes, $collapsedItemsLimit);
656+
}
657+
658+
private function sliceGroupableFilesSearchResult(array $nodes, int $limit): array {
659+
$result = [];
660+
$count = 0;
661+
$seenGroups = [];
662+
663+
foreach ($nodes as $searchResult) {
664+
$node = $searchResult->node;
665+
$group = ($node instanceof GroupableFile) ? $node->getGroup() : null;
666+
667+
if ($group === null) {
668+
$count++;
669+
} elseif (!isset($seenGroups[$group])) {
670+
$seenGroups[$group] = true;
671+
$count++;
672+
}
673+
674+
if ($count > $limit) {
675+
return $result;
676+
}
677+
678+
$result[] = $searchResult;
679+
}
680+
681+
return $result;
682+
}
575683
}

apps/dav/lib/Server.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@
6868
use OCA\DAV\Paginate\PaginatePlugin;
6969
use OCA\DAV\Profiler\ProfilerPlugin;
7070
use OCA\DAV\Provisioning\Apple\AppleProvisioningPlugin;
71+
use OCA\DAV\Service\FileGroupingService;
7172
use OCA\DAV\SystemTag\SystemTagPlugin;
7273
use OCA\DAV\Upload\ChunkingPlugin;
7374
use OCA\DAV\Upload\ChunkingV2Plugin;
@@ -382,7 +383,9 @@ public function __construct(
382383
\OCP\Server::get(IRootFolder::class),
383384
$shareManager,
384385
$view,
385-
\OCP\Server::get(IFilesMetadataManager::class)
386+
\OCP\Server::get(IFilesMetadataManager::class),
387+
\OCP\Server::get(FileGroupingService::class),
388+
\OCP\Server::get(IAppConfig::class),
386389
));
387390
$this->server->addPlugin(
388391
new BulkUploadPlugin(

0 commit comments

Comments
 (0)