Skip to content

Commit c2170cb

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

9 files changed

Lines changed: 314 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: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
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 OCA\DAV\Connector\Sabre\File;
12+
use OCP\Files\FileInfo;
13+
use OCP\IL10N;
14+
use OCP\IRequest;
15+
use OCP\Share\IManager;
16+
17+
class GroupableFile extends File {
18+
19+
public function __construct(
20+
View $view,
21+
FileInfo $info,
22+
?IManager $shareManager = null,
23+
?IRequest $request = null,
24+
?IL10N $l10n = null,
25+
protected ?int $group = null,
26+
) {
27+
parent::__construct($view, $info, $shareManager, $request, $l10n);
28+
}
29+
30+
public function getGroup(): ?int {
31+
return $this->group;
32+
}
33+
34+
public function setGroup(int $group): void {
35+
$this->group = $group;
36+
}
37+
}

apps/dav/lib/Files/FileSearchBackend.php

Lines changed: 123 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,16 @@
1313
use OC\Files\Search\SearchQuery;
1414
use OC\Files\Storage\Wrapper\Jail;
1515
use OC\Files\View;
16+
use OCA\Files\AppInfo\Application;
17+
use OCA\Files\ConfigLexicon;
1618
use OCA\DAV\Connector\Sabre\CachingTree;
1719
use OCA\DAV\Connector\Sabre\Directory;
1820
use OCA\DAV\Connector\Sabre\File;
1921
use OCA\DAV\Connector\Sabre\FilesPlugin;
22+
use OCA\DAV\Connector\Sabre\GroupableFile;
2023
use OCA\DAV\Connector\Sabre\Server;
2124
use OCA\DAV\Connector\Sabre\TagsPlugin;
25+
use OCA\DAV\Service\FileGroupingService;
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,82 @@ 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+
$minGroupSize = $this->appConfig->getValueInt(Application::APP_ID, ConfigLexicon::RECENT_FILES_GROUP_MIN_GROUP_SIZE, 2);
622+
$timespanMinutes = $this->appConfig->getValueInt(Application::APP_ID, ConfigLexicon::RECENT_FILES_GROUP_TIMESPAN_MINUTES, 2);
623+
$collapsedItemsLimit = $this->appConfig->getValueInt(Application::APP_ID, ConfigLexicon::RECENT_FILES_GROUP_COLLAPSED_ITEMS_LIMIT, 25);
624+
625+
[$nodes, $collapsedCount] = $this->fileGroupingService->setGroupOnNodes($nodes, $mimeTypes, $minGroupSize, $timespanMinutes);
626+
627+
$queryLimit = $query->getLimit();
628+
$queryOffset = $query->getOffset();
629+
$maxExtraFetches = 5;
630+
631+
for ($i = 0; $i < $maxExtraFetches; $i++) {
632+
$lastNode = $nodes[array_key_last($nodes)] ?? null;
633+
$lastGroupMightContinue = $lastNode !== null && $this->fileGroupingService->isNodeGroupable($lastNode, $mimeTypes);
634+
635+
$needsMore = count($results) === $queryLimit && ($collapsedCount < $collapsedItemsLimit || $lastGroupMightContinue);
636+
if (!$needsMore) {
637+
break;
638+
}
639+
640+
$queryOffset += $queryLimit;
641+
$search->limit->firstResult = $queryOffset;
642+
$query = $this->transformQuery($search);
643+
$results = $searchTarget->search($query);
644+
645+
if (empty($results)) {
646+
break;
647+
}
648+
649+
$extraNodes = $this->mapNodesToSearchResults($results, true);
650+
$nodes = array_merge($nodes, $extraNodes);
651+
[$nodes, $collapsedCount] = $this->fileGroupingService->setGroupOnNodes($nodes, $mimeTypes, $minGroupSize, $timespanMinutes);
652+
}
653+
654+
return $this->sliceGroupableFilesSearchResult($nodes, $collapsedItemsLimit);
655+
}
656+
657+
private function sliceGroupableFilesSearchResult(array $nodes, int $limit): array {
658+
$result = [];
659+
$count = 0;
660+
$seenGroups = [];
661+
662+
foreach ($nodes as $searchResult) {
663+
$node = $searchResult->node;
664+
$group = ($node instanceof GroupableFile) ? $node->getGroup() : null;
665+
666+
if ($group === null) {
667+
$count++;
668+
} elseif (!isset($seenGroups[$group])) {
669+
$seenGroups[$group] = true;
670+
$count++;
671+
}
672+
673+
if ($count > $limit) {
674+
return $result;
675+
}
676+
677+
$result[] = $searchResult;
678+
}
679+
680+
return $result;
681+
}
575682
}

apps/dav/lib/Server.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@
7272
use OCA\DAV\Upload\ChunkingPlugin;
7373
use OCA\DAV\Upload\ChunkingV2Plugin;
7474
use OCA\DAV\Upload\UploadAutoMkcolPlugin;
75+
use OCA\DAV\Service\FileGroupingService;
7576
use OCA\Theming\ThemingDefaults;
7677
use OCP\Accounts\IAccountManager;
7778
use OCP\App\IAppManager;
@@ -381,7 +382,9 @@ public function __construct(
381382
\OCP\Server::get(IRootFolder::class),
382383
$shareManager,
383384
$view,
384-
\OCP\Server::get(IFilesMetadataManager::class)
385+
\OCP\Server::get(IFilesMetadataManager::class),
386+
\OCP\Server::get(FileGroupingService::class),
387+
\OCP\Server::get(IAppConfig::class),
385388
));
386389
$this->server->addPlugin(
387390
new BulkUploadPlugin(

0 commit comments

Comments
 (0)