Skip to content

Commit 795d7ac

Browse files
committed
Perf: Optimize pages loading (code review fixes)
Signed-off-by: Kostiantyn Miakshyn <molodchick@gmail.com>
1 parent 8806c19 commit 795d7ac

4 files changed

Lines changed: 409 additions & 208 deletions

File tree

Lines changed: 346 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,346 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\Collectives\Service;
11+
12+
use OCA\Collectives\Db\Page;
13+
use OCA\Collectives\Db\PageLinkMapper;
14+
use OCA\Collectives\Db\PageMapper;
15+
use OCA\Collectives\Model\CollectiveFileInfo;
16+
use OCA\Collectives\Model\PageInfo;
17+
use OCA\Collectives\Mount\CollectiveFolderManager;
18+
use OCP\DB\Exception as DBException;
19+
use OCP\Files\Folder;
20+
use OCP\Files\InvalidPathException;
21+
use OCP\Files\NotFoundException as FilesNotFoundException;
22+
use OCP\Files\NotPermittedException as FilesNotPermittedException;
23+
use OCP\IUserManager;
24+
use Symfony\Component\String\Slugger\SluggerInterface;
25+
26+
/**
27+
* Builds the PageInfo tree for a single folder of a collective.
28+
*
29+
* One instance is created per call (see PageInfoTreeBuilderFactory). The filecache
30+
* lookup, batched metadata queries and other values that only depend on the
31+
* constructor arguments are computed lazily and cached for the lifetime of the
32+
* instance.
33+
*/
34+
class PageInfoTreeBuilder {
35+
/** @var CollectiveFileInfo[]|null */
36+
private ?array $fileInfos = null;
37+
/** @var array<int, CollectiveFileInfo[]>|null */
38+
private ?array $childrenByParent = null;
39+
/** @var int[]|null */
40+
private ?array $pageFileIds = null;
41+
/** @var array<int, Page>|null */
42+
private ?array $pagesByFileId = null;
43+
/** @var array<int, int[]>|null */
44+
private ?array $linkedPageIdsByFileId = null;
45+
/** @var array<string, null|string>|null */
46+
private ?array $displayNames = null;
47+
private ?string $collectivePath = null;
48+
49+
public function __construct(
50+
private readonly PageMapper $pageMapper,
51+
private readonly PageLinkMapper $pageLinkMapper,
52+
private readonly IUserManager $userManager,
53+
private readonly SluggerInterface $slugger,
54+
private readonly CollectiveFolderManager $collectiveFolderManager,
55+
private readonly CollectiveServiceBase $collectiveService,
56+
private readonly int $collectiveId,
57+
private readonly Folder $folder,
58+
private readonly string $userId,
59+
private readonly bool $recurse,
60+
private readonly bool $forceIndex,
61+
) {
62+
}
63+
64+
/**
65+
* Recursively build PageInfo objects from the in-memory filecache tree.
66+
*
67+
* @param PageInfo[] $pageInfos
68+
*
69+
* @throws MissingDependencyException
70+
* @throws NotFoundException
71+
* @throws NotPermittedException
72+
*/
73+
public function build(int $folderId, int $parentPageId, array &$pageInfos): void {
74+
$indexFileInfo = null;
75+
$pageFileInfos = [];
76+
$subFolderIds = [];
77+
foreach ($this->childrenByParent()[$folderId] ?? [] as $child) {
78+
if (str_starts_with($child->name, '.')) {
79+
// Ignore hidden folders and files
80+
continue;
81+
}
82+
83+
if (isset($this->childrenByParent()[$child->fileId])) {
84+
// Has children, so it's a (non-empty) folder
85+
$subFolderIds[] = $child->fileId;
86+
} elseif ($child->isIndexPage()) {
87+
$indexFileInfo = $child;
88+
} elseif ($child->isPage()) {
89+
$pageFileInfos[] = $child;
90+
}
91+
}
92+
93+
// forceIndex only applies to the entry folder, not to subfolders
94+
$forceIndex = $this->forceIndex && $folderId === $this->folder->getId();
95+
96+
if ($indexFileInfo === null) {
97+
if (!$forceIndex && !$this->folderHasPages($folderId)) {
98+
// Ignore folders without an index page and without any (sub)pages
99+
return;
100+
}
101+
102+
// Create missing index page if folder or subfolders have page files (or forceIndex)
103+
$folder = $this->folder->getFirstNodeById($folderId);
104+
if (!($folder instanceof Folder)) {
105+
return;
106+
}
107+
$indexPageInfo = $this->createIndexPage($folder, $parentPageId);
108+
$indexPageId = $indexPageInfo->getId();
109+
} else {
110+
$indexPageInfo = $this->buildPageInfo($indexFileInfo, $parentPageId);
111+
$indexPageId = $indexFileInfo->fileId;
112+
}
113+
$pageInfos[] = $indexPageInfo;
114+
115+
foreach ($pageFileInfos as $pageFileInfo) {
116+
$pageInfos[] = $this->buildPageInfo($pageFileInfo, $indexPageId);
117+
}
118+
119+
foreach ($subFolderIds as $subFolderId) {
120+
if ($this->recurse) {
121+
$this->build($subFolderId, $indexPageId, $pageInfos);
122+
continue;
123+
}
124+
125+
// Not recursive: only add the subfolder's index page (ignore subfolders without one)
126+
$subIndexFileInfo = $this->findIndexPageInfo($subFolderId);
127+
if ($subIndexFileInfo !== null) {
128+
$pageInfos[] = $this->buildPageInfo($subIndexFileInfo, $indexPageId);
129+
}
130+
}
131+
}
132+
133+
/**
134+
* @return CollectiveFileInfo[]
135+
*
136+
* @throws NotFoundException
137+
*/
138+
private function fileInfos(): array {
139+
if ($this->fileInfos === null) {
140+
try {
141+
$this->fileInfos = $this->collectiveFolderManager->getFileCacheForCollective($this->collectiveId, $this->folder->getInternalPath());
142+
} catch (DBException $e) {
143+
throw new NotFoundException($e->getMessage(), 0, $e);
144+
}
145+
}
146+
147+
return $this->fileInfos;
148+
}
149+
150+
/**
151+
* Group child entries by their parent folder file id.
152+
*
153+
* @return array<int, CollectiveFileInfo[]>
154+
*
155+
* @throws NotFoundException
156+
*/
157+
private function childrenByParent(): array {
158+
if ($this->childrenByParent === null) {
159+
$this->childrenByParent = [];
160+
foreach ($this->fileInfos() as $fileInfo) {
161+
$this->childrenByParent[$fileInfo->parent][] = $fileInfo;
162+
}
163+
}
164+
165+
return $this->childrenByParent;
166+
}
167+
168+
/**
169+
* File ids of all page files in the tree.
170+
*
171+
* @return int[]
172+
*
173+
* @throws NotFoundException
174+
*/
175+
private function pageFileIds(): array {
176+
if ($this->pageFileIds === null) {
177+
$this->pageFileIds = [];
178+
foreach ($this->fileInfos() as $fileInfo) {
179+
if ($fileInfo->isPage()) {
180+
$this->pageFileIds[] = $fileInfo->fileId;
181+
}
182+
}
183+
}
184+
185+
return $this->pageFileIds;
186+
}
187+
188+
/**
189+
* Batch load page metadata for all page files.
190+
*
191+
* @return array<int, Page>
192+
*
193+
* @throws NotFoundException
194+
*/
195+
private function pagesByFileId(): array {
196+
if ($this->pagesByFileId === null) {
197+
$this->pagesByFileId = $this->pageMapper->findByFileIds($this->pageFileIds());
198+
}
199+
200+
return $this->pagesByFileId;
201+
}
202+
203+
/**
204+
* @return array<int, int[]>
205+
*
206+
* @throws NotFoundException
207+
*/
208+
private function linkedPageIdsByFileId(): array {
209+
if ($this->linkedPageIdsByFileId === null) {
210+
$this->linkedPageIdsByFileId = $this->pageLinkMapper->findByPageIds($this->pageFileIds());
211+
}
212+
213+
return $this->linkedPageIdsByFileId;
214+
}
215+
216+
/**
217+
* @return array<string, null|string>
218+
*
219+
* @throws NotFoundException
220+
*/
221+
private function displayNames(): array {
222+
if ($this->displayNames === null) {
223+
$this->displayNames = [];
224+
foreach ($this->pagesByFileId() as $page) {
225+
$lastUserId = $page->getLastUserId();
226+
if ($lastUserId !== null && !isset($this->displayNames[$lastUserId])) {
227+
$this->displayNames[$lastUserId] = $this->userManager->getDisplayName($lastUserId);
228+
}
229+
}
230+
}
231+
232+
return $this->displayNames;
233+
}
234+
235+
/**
236+
* Derive collectivePath from the mount point (incl. user folder prefix),
237+
* matching PageInfo::fromFile().
238+
*
239+
* @throws MissingDependencyException
240+
* @throws NotFoundException
241+
* @throws NotPermittedException
242+
*/
243+
private function collectivePath(): string {
244+
if ($this->collectivePath === null) {
245+
$mountPoint = explode('/', $this->folder->getMountPoint()->getMountPoint(), 4);
246+
$this->collectivePath = (count($mountPoint) >= 4)
247+
? rtrim($mountPoint[3], '/')
248+
: $this->collectiveService->getCollective($this->collectiveId, $this->userId)->getName();
249+
}
250+
251+
return $this->collectivePath;
252+
}
253+
254+
/**
255+
* @throws NotFoundException
256+
*/
257+
private function findIndexPageInfo(int $folderId): ?CollectiveFileInfo {
258+
foreach ($this->childrenByParent()[$folderId] ?? [] as $child) {
259+
if ($child->isIndexPage()) {
260+
return $child;
261+
}
262+
}
263+
264+
return null;
265+
}
266+
267+
/**
268+
* @throws NotFoundException
269+
*/
270+
private function folderHasPages(int $folderId): bool {
271+
foreach ($this->childrenByParent()[$folderId] ?? [] as $child) {
272+
if (str_starts_with($child->name, '.')) {
273+
continue;
274+
}
275+
276+
if (isset($this->childrenByParent()[$child->fileId])) {
277+
if ($this->folderHasPages($child->fileId)) {
278+
return true;
279+
}
280+
} elseif ($child->isPage()) {
281+
return true;
282+
}
283+
}
284+
285+
return false;
286+
}
287+
288+
/**
289+
* @throws MissingDependencyException
290+
* @throws NotFoundException
291+
* @throws NotPermittedException
292+
*/
293+
private function buildPageInfo(CollectiveFileInfo $fileInfo, int $parentId): PageInfo {
294+
$page = $this->pagesByFileId()[$fileInfo->fileId] ?? null;
295+
$lastUserId = $page?->getLastUserId();
296+
$pageInfo = new PageInfo();
297+
$pageInfo->fromFileInfo(
298+
$fileInfo,
299+
$parentId,
300+
$this->collectivePath(),
301+
$lastUserId,
302+
$lastUserId !== null ? ($this->displayNames()[$lastUserId] ?? null) : null,
303+
$page?->getEmoji(),
304+
$page?->getSubpageOrder(),
305+
$page !== null && $page->getFullWidth(),
306+
$page?->getSlug(),
307+
$page?->getTags(),
308+
$this->linkedPageIdsByFileId()[$fileInfo->fileId] ?? null,
309+
);
310+
311+
return $pageInfo;
312+
}
313+
314+
/**
315+
* @throws NotFoundException
316+
* @throws NotPermittedException
317+
*/
318+
private function createIndexPage(Folder $folder, int $parentPageId): PageInfo {
319+
try {
320+
$newFile = $folder->newFile(PageInfo::INDEX_PAGE_TITLE . PageInfo::SUFFIX);
321+
} catch (FilesNotPermittedException $e) {
322+
throw new NotPermittedException($e->getMessage(), 0, $e);
323+
}
324+
325+
$pageInfo = new PageInfo();
326+
try {
327+
$pageInfo->fromFile(
328+
$newFile,
329+
$parentPageId,
330+
$this->userId,
331+
$this->userManager->getDisplayName($this->userId),
332+
);
333+
$slug = $this->slugger->slug(PageInfo::INDEX_PAGE_TITLE)->toString();
334+
$page = new Page();
335+
$page->setFileId($newFile->getId());
336+
$page->setLastUserId($this->userId);
337+
$page->setSlug($slug);
338+
$this->pageMapper->updateOrInsert($page);
339+
$pageInfo->setSlug($slug);
340+
} catch (FilesNotFoundException|InvalidPathException $e) {
341+
throw new NotFoundException($e->getMessage(), 0, $e);
342+
}
343+
344+
return $pageInfo;
345+
}
346+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\Collectives\Service;
11+
12+
use OCA\Collectives\Db\PageLinkMapper;
13+
use OCA\Collectives\Db\PageMapper;
14+
use OCA\Collectives\Mount\CollectiveFolderManager;
15+
use OCP\Files\Folder;
16+
use OCP\IUserManager;
17+
use Symfony\Component\String\Slugger\SluggerInterface;
18+
19+
/**
20+
* Combines the dependency-injected collaborators with the per-call arguments
21+
* required to build a PageInfoTreeBuilder.
22+
*/
23+
class PageInfoTreeBuilderFactory {
24+
public function __construct(
25+
private readonly PageMapper $pageMapper,
26+
private readonly PageLinkMapper $pageLinkMapper,
27+
private readonly IUserManager $userManager,
28+
private readonly SluggerInterface $slugger,
29+
private readonly CollectiveFolderManager $collectiveFolderManager,
30+
private readonly CollectiveServiceBase $collectiveService,
31+
) {
32+
}
33+
34+
public function create(int $collectiveId, Folder $folder, string $userId, bool $recurse, bool $forceIndex): PageInfoTreeBuilder {
35+
return new PageInfoTreeBuilder(
36+
$this->pageMapper,
37+
$this->pageLinkMapper,
38+
$this->userManager,
39+
$this->slugger,
40+
$this->collectiveFolderManager,
41+
$this->collectiveService,
42+
$collectiveId,
43+
$folder,
44+
$userId,
45+
$recurse,
46+
$forceIndex,
47+
);
48+
}
49+
}

0 commit comments

Comments
 (0)