diff --git a/appinfo/info.xml b/appinfo/info.xml
index 3d50bbf7c..03610c512 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -26,7 +26,7 @@ In your Nextcloud instance, simply navigate to **»Apps«**, find the
**»Teams«** and **»Collectives«** apps and enable them.
]]>
- 4.2.0
+ 4.2.1
agpl
CollectiveCloud Team
Collectives
@@ -59,6 +59,7 @@ In your Nextcloud instance, simply navigate to **»Apps«**, find the
OCA\Collectives\Migration\MigrateTemplates
+ OCA\Collectives\Migration\PopulatePageCollectiveId
OCA\Collectives\Migration\GenerateSlugs
diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php
index 8bc17e0fe..6e71ae1d2 100644
--- a/lib/AppInfo/Application.php
+++ b/lib/AppInfo/Application.php
@@ -23,6 +23,7 @@
use OCA\Collectives\Listeners\CircleDestroyedListener;
use OCA\Collectives\Listeners\CircleEditingEventListener;
use OCA\Collectives\Listeners\CollectivesReferenceListener;
+use OCA\Collectives\Listeners\NodeDeletedListener;
use OCA\Collectives\Listeners\NodeRenamedListener;
use OCA\Collectives\Listeners\NodeWrittenListener;
use OCA\Collectives\Listeners\ShareDeletedListener;
@@ -52,6 +53,7 @@
use OCP\Collaboration\Reference\RenderReferenceEvent;
use OCP\Dashboard\IAPIWidgetV2;
use OCP\Files\Config\IMountProviderCollection;
+use OCP\Files\Events\Node\NodeDeletedEvent;
use OCP\Files\Events\Node\NodeRenamedEvent;
use OCP\Files\Events\Node\NodeWrittenEvent;
use OCP\Files\IMimeTypeLoader;
@@ -75,6 +77,7 @@ public function __construct(array $urlParams = []) {
public function register(IRegistrationContext $context): void {
require_once(__DIR__ . '/../../vendor/autoload.php');
$context->registerEventListener(BeforeTemplateRenderedEvent::class, BeforeTemplateRenderedListener::class);
+ $context->registerEventListener(NodeDeletedEvent::class, NodeDeletedListener::class);
$context->registerEventListener(NodeRenamedEvent::class, NodeRenamedListener::class);
$context->registerEventListener(NodeWrittenEvent::class, NodeWrittenListener::class);
$context->registerEventListener(CircleDestroyedEvent::class, CircleDestroyedListener::class);
diff --git a/lib/Command/GenerateSlugs.php b/lib/Command/GenerateSlugs.php
index 8d51acb83..9fddfb41f 100644
--- a/lib/Command/GenerateSlugs.php
+++ b/lib/Command/GenerateSlugs.php
@@ -12,10 +12,12 @@
use OCA\Collectives\Fs\NodeHelper;
use OCA\Collectives\Model\PageInfo;
use OCA\Collectives\Service\CircleHelper;
+use OCA\Collectives\Service\MissingDependencyException;
use OCA\Collectives\Service\NotFoundException;
use OCA\Collectives\Service\NotPermittedException;
use OCA\Collectives\Service\PageService;
use OCP\DB\QueryBuilder\IQueryBuilder;
+use OCP\Files\NotFoundException as FilesNotFoundException;
use OCP\IDBConnection;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
@@ -98,7 +100,7 @@ private function generatePageSlugs(): void {
while ($rowCollective = $resultCollectives->fetch()) {
try {
$circle = $this->circleHelper->getCircle($rowCollective['circle_unique_id'], null, true);
- $pageInfos = $this->pageService->findAll($rowCollective['id'], $circle->getOwner()->getUserId());
+ $pageInfos = $this->findAllPages((int)$rowCollective['id'], $circle->getOwner()->getUserId());
} catch (NotFoundException|NotPermittedException) {
// Ignore exceptions from CircleManager (e.g. due to cruft collective without circle)
continue;
@@ -121,4 +123,18 @@ private function generatePageSlugs(): void {
$resultCollectives->closeCursor();
}
+
+ /**
+ * @throws MissingDependencyException
+ * @throws NotFoundException
+ * @throws NotPermittedException
+ */
+ private function findAllPages(int $collectiveId, string $userId): array {
+ $folder = $this->pageService->getCollectiveFolder($collectiveId, $userId);
+ try {
+ return $this->pageService->getPagesFromFolder($collectiveId, $folder, $userId, true, true);
+ } catch (FilesNotFoundException $e) {
+ throw new NotFoundException($e->getMessage(), 0, $e);
+ }
+ }
}
diff --git a/lib/Db/FileCacheMapper.php b/lib/Db/FileCacheMapper.php
new file mode 100644
index 000000000..b6425c2d0
--- /dev/null
+++ b/lib/Db/FileCacheMapper.php
@@ -0,0 +1,68 @@
+ Indexed by fileId
+ */
+ public function findByFileIds(array $fileIds): array {
+ if (empty($fileIds)) {
+ return [];
+ }
+
+ $fileInfos = [];
+ foreach (array_chunk($fileIds, self::BATCH_SIZE) as $chunk) {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select('fileid', 'storage', 'parent', 'path', 'name', 'mimetype', 'mimepart', 'size', 'mtime', 'storage_mtime', 'etag', 'encrypted', 'permissions', 'checksum', 'unencrypted_size')
+ ->from('filecache')
+ ->where($qb->expr()->in('fileid', $qb->createNamedParameter($chunk, IQueryBuilder::PARAM_INT_ARRAY)));
+
+ $result = $qb->executeQuery();
+ while ($row = $result->fetch()) {
+ $fileInfo = new FileInfo(
+ fileId: (int)$row['fileid'],
+ storage: (int)$row['storage'],
+ path: (string)$row['path'],
+ parent: (int)$row['parent'],
+ name: (string)$row['name'],
+ mimetype: (int)$row['mimetype'],
+ mimepart: (int)$row['mimepart'],
+ size: (int)$row['size'],
+ mtime: (int)$row['mtime'],
+ storage_mtime: (int)$row['storage_mtime'],
+ encrypted: (int)$row['encrypted'],
+ unencrypted_size: (int)$row['unencrypted_size'],
+ etag: (string)$row['etag'],
+ permissions: (int)$row['permissions'],
+ checksum: $row['checksum'] !== null ? (string)$row['checksum'] : null,
+ );
+ $fileInfos[$fileInfo->fileId] = $fileInfo;
+ }
+ $result->closeCursor();
+ }
+
+ return $fileInfos;
+ }
+}
diff --git a/lib/Db/Page.php b/lib/Db/Page.php
index f38afcf05..f9fb9e849 100644
--- a/lib/Db/Page.php
+++ b/lib/Db/Page.php
@@ -17,6 +17,8 @@
/**
* @method int getId()
* @method void setId(int $value)
+ * @method int getCollectiveId()
+ * @method void setCollectiveId(int $value)
* @method int getFileId()
* @method void setFileId(int $value)
* @method string getSlug()
@@ -36,6 +38,7 @@
*/
class Page extends Entity implements JsonSerializable {
protected ?int $fileId = null;
+ protected ?int $collectiveId = null;
protected ?string $slug = null;
protected ?string $lastUserId = null;
protected ?string $emoji = null;
@@ -51,6 +54,7 @@ public function __construct() {
public function jsonSerialize(): array {
return [
'id' => $this->id,
+ 'collectiveId' => $this->collectiveId,
'fileId' => $this->fileId,
'slug' => $this->slug,
'lastUserId' => $this->lastUserId,
diff --git a/lib/Db/PageLinkMapper.php b/lib/Db/PageLinkMapper.php
index 64c399a9c..ec999e976 100644
--- a/lib/Db/PageLinkMapper.php
+++ b/lib/Db/PageLinkMapper.php
@@ -10,6 +10,7 @@
namespace OCA\Collectives\Db;
use OCP\DB\Exception;
+use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;
class PageLinkMapper {
@@ -32,6 +33,36 @@ public function findByPageId(int $pageId): array {
return array_values($result);
}
+ /**
+ * @param int[] $pageIds
+ * @return array> Indexed by page_id, values are arrays of linked_page_ids
+ * @throws Exception
+ */
+ public function findByPageIds(array $pageIds): array {
+ if (empty($pageIds)) {
+ return [];
+ }
+
+ $qb = $this->db->getQueryBuilder();
+ $qb->select('page_id', 'linked_page_id')
+ ->from(self::TABLE_NAME)
+ ->where($qb->expr()->in('page_id', $qb->createNamedParameter($pageIds, IQueryBuilder::PARAM_INT_ARRAY)));
+
+ $result = $qb->executeQuery();
+ $linkedPagesByPageId = [];
+ while ($row = $result->fetch()) {
+ $pageId = (int)$row['page_id'];
+ $linkedPageId = (int)$row['linked_page_id'];
+ if (!isset($linkedPagesByPageId[$pageId])) {
+ $linkedPagesByPageId[$pageId] = [];
+ }
+ $linkedPagesByPageId[$pageId][] = $linkedPageId;
+ }
+ $result->closeCursor();
+
+ return $linkedPagesByPageId;
+ }
+
/**
* @throws Exception
*/
diff --git a/lib/Db/PageMapper.php b/lib/Db/PageMapper.php
index 536c08b15..330a1cf79 100644
--- a/lib/Db/PageMapper.php
+++ b/lib/Db/PageMapper.php
@@ -110,6 +110,26 @@ public function findByFileIds(array $fileIds, bool $trashed = false): array {
return $pagesByFileId;
}
+ /**
+ * @return Page[]
+ */
+ public function findByCollectiveId(int $collectiveId, bool $trashed = false): array {
+ $qb = $this->db->getQueryBuilder();
+ $andX = [
+ $qb->expr()->eq('collective_id', $qb->createNamedParameter($collectiveId, IQueryBuilder::PARAM_INT)),
+ ];
+ // fixme: change index to use timestamp as well?
+ if ($trashed) {
+ $andX[] = $qb->expr()->isNotNull('trash_timestamp');
+ } else {
+ $andX[] = $qb->expr()->isNull('trash_timestamp');
+ }
+ $qb->select('*')
+ ->from($this->tableName)
+ ->where($qb->expr()->andX(...$andX));
+ return $this->findEntities($qb);
+ }
+
/**
* @return Page[]
*/
diff --git a/lib/Fs/NodeHelper.php b/lib/Fs/NodeHelper.php
index 80806d121..09cd4a5cc 100644
--- a/lib/Fs/NodeHelper.php
+++ b/lib/Fs/NodeHelper.php
@@ -10,12 +10,14 @@
namespace OCA\Collectives\Fs;
use OCA\Collectives\Model\PageInfo;
+use OCA\Collectives\Mount\CollectiveStorage;
use OCA\Collectives\Service\NotFoundException;
use OCA\Collectives\Service\NotPermittedException;
use OCP\Files\File;
use OCP\Files\Folder;
use OCP\Files\GenericFileException;
use OCP\Files\InvalidPathException;
+use OCP\Files\Node;
use OCP\Files\NotFoundException as FilesNotFoundException;
use OCP\Files\NotPermittedException as FilesNotPermittedException;
use OCP\IDBConnection;
@@ -251,4 +253,45 @@ public static function folderHasSubPage(Folder $folder, string $title): int {
return 0;
}
+
+ /**
+ * Extract collective_id from path like "appdata_/collectives//..."
+ * Only processes collective pages (not versions)
+ */
+ public static function extractCollectiveIdFromPath(string $path): ?int {
+ $parts = explode('/', $path);
+ if (!isset($parts[0]) || !str_starts_with($parts[0], 'appdata_')) {
+ return null;
+ }
+ if (!isset($parts[1]) || $parts[1] !== 'collectives') {
+ return null;
+ }
+
+ $collectiveId = $parts[2] ?? null;
+ if ($collectiveId === 'trash') {
+ $collectiveId = $parts[3] ?? null;
+ }
+ if (!is_numeric($collectiveId)) {
+ return null;
+ }
+
+ return (int)$collectiveId;
+ }
+
+ /**
+ * Get collective ID from node if it is a markdown file belonging to a CollectiveStorage
+ */
+ public static function getCollectiveIdFromNode(Node $node): ?int {
+ if (!($node instanceof File) || !self::isPage($node)) {
+ return null;
+ }
+
+ $storage = $node->getStorage();
+ if (!$storage->instanceOfStorage(CollectiveStorage::class)) {
+ return null;
+ }
+
+ /** @var CollectiveStorage $storage */
+ return $storage->getFolderId();
+ }
}
diff --git a/lib/Listeners/NodeDeletedListener.php b/lib/Listeners/NodeDeletedListener.php
new file mode 100644
index 000000000..d15ac8c9e
--- /dev/null
+++ b/lib/Listeners/NodeDeletedListener.php
@@ -0,0 +1,44 @@
+ */
+class NodeDeletedListener implements IEventListener {
+ public function __construct(
+ private PageMapper $pageMapper,
+ private PageService $pageService,
+ ) {
+ }
+
+ public function handle(Event $event): void {
+ if (!($event instanceof NodeDeletedEvent)) {
+ return;
+ }
+
+ if ($this->pageService->isFromCollectives()) {
+ return;
+ }
+
+ $node = $event->getNode();
+ $collectiveId = NodeHelper::getCollectiveIdFromNode($node);
+ if ($collectiveId === null) {
+ return;
+ }
+
+ $this->pageMapper->trashByFileId($node->getId());
+ }
+}
diff --git a/lib/Listeners/NodeRenamedListener.php b/lib/Listeners/NodeRenamedListener.php
index 0b7d6ecf0..846f0d991 100644
--- a/lib/Listeners/NodeRenamedListener.php
+++ b/lib/Listeners/NodeRenamedListener.php
@@ -11,17 +11,22 @@
use OCA\Collectives\Db\PageMapper;
use OCA\Collectives\Fs\NodeHelper;
+use OCA\Collectives\Mount\CollectiveStorage;
+use OCA\Collectives\Service\PageService;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\Files\Events\Node\NodeRenamedEvent;
use OCP\Files\File;
-use Symfony\Component\String\Slugger\SluggerInterface;
+use OCP\Files\Folder;
+use OCP\Files\Node;
+use Psr\Log\LoggerInterface;
/** @template-implements IEventListener */
class NodeRenamedListener implements IEventListener {
public function __construct(
- private PageMapper $pageMapper,
- private SluggerInterface $slugger,
+ private readonly PageService $pageService,
+ private readonly PageMapper $pageMapper,
+ private readonly LoggerInterface $logger,
) {
}
@@ -29,20 +34,113 @@ public function handle(Event $event): void {
if (!($event instanceof NodeRenamedEvent)) {
return;
}
- $source = $event->getSource();
+
+ if ($this->pageService->isFromCollectives()) {
+ return;
+ }
+
$target = $event->getTarget();
+ $source = $event->getSource();
- if (!($source instanceof File && $target instanceof File)) {
+ if ($target instanceof Folder) {
+ $this->handleFolderRenamed($source, $target);
return;
}
- $page = $this->pageMapper->findByFileId($target->getId());
- if ($page === null) {
+ if ($target instanceof File) {
+ $this->handleFileRenamed($source, $target);
+ }
+ }
+
+ private function handleFolderRenamed(Node $source, Folder $target): void {
+ $sourceCollectiveId = ($source instanceof Folder) ? $this->getCollectiveIdFromFolder($source) : null;
+ $targetCollectiveId = $this->getCollectiveIdFromFolder($target);
+
+ if ($targetCollectiveId === null) {
+ // Folder moved out of collective - trash all pages
+ $this->trashFolderPages($target);
+ return;
+ }
+
+ // Folder moved into or between collectives - process all files recursively
+ $this->processFilesInFolder($target, $targetCollectiveId, $sourceCollectiveId);
+ }
+
+ private function handleFileRenamed(Node $source, File $target): void {
+ $targetCollectiveId = NodeHelper::getCollectiveIdFromNode($target);
+
+ if ($targetCollectiveId === null) {
+ // File moved out of collective - trash the page
+ $this->pageMapper->trashByFileId($target->getId());
return;
}
- $title = NodeHelper::getTitleFromFile($target);
- $page->setSlug($this->slugger->slug($title)->toString());
- $this->pageMapper->update($page);
+ // File moved into a collective or between collectives - update collectiveId
+ $title = null;
+ if ($source->getName() !== $target->getName()) {
+ $title = NodeHelper::getTitleFromFile($target);
+ }
+ $userId = $target->getOwner()?->getUID();
+ $this->pageService->updatePage($targetCollectiveId, $target->getId(), $userId, title: $title);
+ }
+
+ private function getCollectiveIdFromFolder(Folder $folder): ?int {
+ // Check if folder belongs to a CollectiveStorage
+ $storage = $folder->getStorage();
+ if ($storage->instanceOfStorage(CollectiveStorage::class)) {
+ /** @var CollectiveStorage $storage */
+ return $storage->getFolderId();
+ }
+
+ // Fallback: Try to get collective ID from folder's internal path
+ $internalPath = $folder->getInternalPath();
+ if ($internalPath !== null) {
+ return NodeHelper::extractCollectiveIdFromPath($internalPath);
+ }
+
+ return null;
+ }
+
+ private function trashFolderPages(Folder $folder): void {
+ try {
+ $nodes = $folder->getDirectoryListing();
+ foreach ($nodes as $node) {
+ if ($node instanceof File && NodeHelper::isPage($node)) {
+ $this->pageMapper->trashByFileId($node->getId());
+ } elseif ($node instanceof Folder) {
+ $this->trashFolderPages($node);
+ }
+ }
+ } catch (\Exception $e) {
+ $this->logger->error('Collectives App Error: ' . $e->getMessage(),
+ ['exception' => $e]
+ );
+ }
+ }
+
+ private function processFilesInFolder(Folder $folder, int $targetCollectiveId, ?int $sourceCollectiveId): void {
+ try {
+ $nodes = $folder->getDirectoryListing();
+ foreach ($nodes as $node) {
+ if (str_starts_with($node->getName(), '.')) {
+ // Skip hidden files/folders
+ continue;
+ }
+
+ if ($node instanceof File && NodeHelper::isPage($node)) {
+ // Update or create page for this file
+ $userId = $node->getOwner()?->getUID();
+ $title = NodeHelper::getTitleFromFile($node);
+ $this->pageService->updatePage($targetCollectiveId, $node->getId(), $userId, title: $title);
+ } elseif ($node instanceof Folder) {
+ // Recursively process subfolders
+ $this->processFilesInFolder($node, $targetCollectiveId, $sourceCollectiveId);
+ }
+ }
+ } catch (\Exception $e) {
+ $this->logger->error('Collectives App Error: ' . $e->getMessage(),
+ ['exception' => $e]
+ );
+ }
}
}
diff --git a/lib/Listeners/NodeWrittenListener.php b/lib/Listeners/NodeWrittenListener.php
index 4d9bf53af..1ba783473 100644
--- a/lib/Listeners/NodeWrittenListener.php
+++ b/lib/Listeners/NodeWrittenListener.php
@@ -12,11 +12,12 @@
use OCA\Collectives\Db\CollectiveMapper;
use OCA\Collectives\Db\PageLinkMapper;
use OCA\Collectives\Fs\MarkdownHelper;
-use OCA\Collectives\Mount\CollectiveStorage;
+use OCA\Collectives\Fs\NodeHelper;
+use OCA\Collectives\Service\PageService;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\Files\Events\Node\NodeWrittenEvent;
-use OCP\Files\File;
+use OCP\Files\Node;
use OCP\IConfig;
/** @template-implements IEventListener */
@@ -25,6 +26,7 @@ public function __construct(
private IConfig $config,
private PageLinkMapper $pageLinkMapper,
private CollectiveMapper $collectiveMapper,
+ private PageService $pageService,
) {
}
@@ -32,19 +34,32 @@ public function handle(Event $event): void {
if (!($event instanceof NodeWrittenEvent)) {
return;
}
+
$node = $event->getNode();
- $storage = $node->getStorage();
- if (!($node instanceof File)
- || $node->getMimeType() !== 'text/markdown'
- || !$node->getStorage()->instanceOfStorage(CollectiveStorage::class)) {
+ $collectiveId = NodeHelper::getCollectiveIdFromNode($node);
+ if ($collectiveId === null) {
return;
}
- /** @var CollectiveStorage $storage */
- $collectiveId = $storage->getFolderId();
- $collective = $this->collectiveMapper->idToCollective($collectiveId);
+ $fileId = $node->getId();
+ $this->updatePageLinks($collectiveId, $node, $fileId);
+
+ if ($this->pageService->isFromCollectives()) {
+ return;
+ }
+ $this->updateCollectivePage($node, $collectiveId, $fileId);
+ }
+
+ private function updatePageLinks(int $collectiveId, Node $node, int $fileId): void {
+ $collective = $this->collectiveMapper->idToCollective($collectiveId);
$linkedPageIds = MarkdownHelper::getLinkedPageIds($collective, $node->getContent(), $this->config->getSystemValue('trusted_domains', []));
- $this->pageLinkMapper->updateByPageId($node->getId(), $linkedPageIds);
+ $this->pageLinkMapper->updateByPageId($fileId, $linkedPageIds);
+ }
+
+ private function updateCollectivePage(Node $node, int $collectiveId, int $fileId): void {
+ $title = NodeHelper::getTitleFromFile($node);
+ $userId = $node->getOwner()->getUID();
+ $this->pageService->updatePage($collectiveId, $fileId, $userId, null, null, $title);
}
}
diff --git a/lib/Migration/PopulatePageCollectiveId.php b/lib/Migration/PopulatePageCollectiveId.php
new file mode 100644
index 000000000..825fd6c77
--- /dev/null
+++ b/lib/Migration/PopulatePageCollectiveId.php
@@ -0,0 +1,113 @@
+config->getValueBool('collectives', 'migrated_collective_id')) {
+ $output->info('collective_id already populated');
+ return;
+ }
+
+ $output->info('Populating collective_id for pages ...');
+
+ $fileIds = $this->getFileIdsWithoutCollectiveId();
+ $output->startProgress(count($fileIds));
+
+ if (empty($fileIds)) {
+ $output->finishProgress();
+ $output->info('No pages need updating');
+ $this->config->setValueBool('collectives', 'migrated_collective_id', true);
+ return;
+ }
+
+ foreach (array_chunk($fileIds, self::BATCH_SIZE) as $chunk) {
+ $this->processBatch($chunk, $output);
+ }
+
+ $output->finishProgress();
+ $output->info('done');
+
+ $this->config->setValueBool('collectives', 'migrated_collective_id', true);
+ }
+
+ /**
+ * Get all file_ids that have null collective_id
+ *
+ * @return list
+ */
+ private function getFileIdsWithoutCollectiveId(): array {
+ $qb = $this->connection->getQueryBuilder();
+ $qb->select('file_id')
+ ->from('collectives_pages')
+ ->where($qb->expr()->isNull('collective_id'));
+
+ $result = $qb->executeQuery();
+ $fileIds = [];
+ while ($row = $result->fetch()) {
+ $fileIds[] = (int)$row['file_id'];
+ }
+ $result->closeCursor();
+
+ return $fileIds;
+ }
+
+ /**
+ * @param list $fileIds
+ */
+ private function processBatch(array $fileIds, IOutput $output): void {
+ $qb = $this->connection->getQueryBuilder();
+ $qb->select(['fileid', 'path'])
+ ->from('filecache')
+ ->where($qb->expr()->in('fileid', $qb->createNamedParameter($fileIds, IQueryBuilder::PARAM_INT_ARRAY)));
+
+ $result = $qb->executeQuery();
+ while ($row = $result->fetch()) {
+ $fileId = (int)$row['fileid'];
+ $collectiveId = NodeHelper::extractCollectiveIdFromPath($row['path']);
+ if (!$collectiveId) {
+ $output->warning("Could not extract collective_id for file_id $fileId with path {$row['path']}");
+ continue;
+ }
+
+ $this->updatePage($fileId, $collectiveId);
+ }
+ }
+
+ private function updatePage(int $fileId, int $collectiveId): void {
+ $qb = $this->connection->getQueryBuilder();
+ $qb->update('collectives_pages')
+ ->set('collective_id', $qb->createParameter('collective_id'))
+ ->where($qb->expr()->eq('file_id', $qb->createParameter('file_id')));
+
+ $qb->setParameter('file_id', $fileId, IQueryBuilder::PARAM_INT)
+ ->setParameter('collective_id', $collectiveId, IQueryBuilder::PARAM_INT)
+ ->executeStatement();
+ }
+}
diff --git a/lib/Migration/Version030401Date20250331000000.php b/lib/Migration/Version030401Date20250331000000.php
new file mode 100644
index 000000000..9b2f95440
--- /dev/null
+++ b/lib/Migration/Version030401Date20250331000000.php
@@ -0,0 +1,46 @@
+hasTable('collectives_pages')) {
+ $table = $schema->getTable('collectives_pages');
+ $changed = false;
+
+ if (!$table->hasColumn('collective_id')) {
+ $table->addColumn('collective_id', Types::BIGINT, [
+ 'notnull' => false,
+ ]);
+ $changed = true;
+ }
+
+ if (!$table->hasIndex('collectives_pages_c_id_idx')) {
+ $table->addIndex(['collective_id'], 'collectives_pages_c_id_idx');
+ $changed = true;
+ }
+
+ if ($changed) {
+ return $schema;
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/lib/Model/FileInfo.php b/lib/Model/FileInfo.php
new file mode 100644
index 000000000..2a90a0e0c
--- /dev/null
+++ b/lib/Model/FileInfo.php
@@ -0,0 +1,91 @@
+name === PageInfo::INDEX_PAGE_TITLE . PageInfo::SUFFIX) {
+ return '';
+ }
+ return basename($this->name, PageInfo::SUFFIX);
+ }
+
+ /**
+ * Check if this is an index page (Readme.md)
+ */
+ public function isIndexPage(): bool {
+ return $this->name === PageInfo::INDEX_PAGE_TITLE . PageInfo::SUFFIX;
+ }
+
+ /**
+ * Extract relative directory path within collective folder
+ *
+ * @param string $collectiveFolderPath Collective folder filecache path (e.g., "appdata_xxx/collectives/11")
+ * @return string Relative path (e.g., "subfolder")
+ */
+ public function getRelativePath(string $collectiveFolderPath): string {
+ $dirPath = dirname($this->path);
+ $prefix = $collectiveFolderPath . '/';
+
+ if (str_starts_with($dirPath, $prefix)) {
+ return substr($dirPath, strlen($prefix));
+ }
+
+ if ($dirPath === $collectiveFolderPath) {
+ return '';
+ }
+
+ return '';
+ }
+
+ /**
+ * Check if this file is inside a hidden folder (starting with .)
+ *
+ * @param string $collectiveFolderPath Collective folder filecache path
+ * @return bool
+ */
+ public function isInHiddenFolder(string $collectiveFolderPath): bool {
+ $relativePath = $this->getRelativePath($collectiveFolderPath);
+ if ($relativePath === '') {
+ return false;
+ }
+ foreach (explode('/', $relativePath) as $segment) {
+ if (str_starts_with($segment, '.')) {
+ return true;
+ }
+ }
+ return false;
+ }
+}
diff --git a/lib/Model/PageInfo.php b/lib/Model/PageInfo.php
index 075ebb4cc..23b2deecb 100644
--- a/lib/Model/PageInfo.php
+++ b/lib/Model/PageInfo.php
@@ -216,6 +216,68 @@ public function jsonSerialize(): array {
];
}
+ public static function fromFileInfo(
+ FileInfo $fileInfo,
+ int $parentId,
+ string $filePath,
+ ?string $lastUserId = null,
+ ?string $lastUserDisplayName = null,
+ ?string $emoji = null,
+ ?string $subpageOrder = null,
+ ?bool $fullWidth = false,
+ ?string $slug = null,
+ ?string $tags = null,
+ ?array $linkedPageIds = null,
+ ): self {
+ $pageInfo = new self();
+ $pageInfo->setId($fileInfo->fileId);
+ $pageInfo->setTimestamp($fileInfo->mtime);
+ $pageInfo->setSize($fileInfo->size);
+ $pageInfo->setFileName($fileInfo->name);
+ $pageInfo->setFilePath($filePath);
+
+ // Set folder name as title for all index pages except the collective landing page
+ if ($fileInfo->isIndexPage()) {
+ if ($parentId === 0) {
+ // Landing page
+ $pageInfo->setTitle(Server::get(IFactory::class)->get('collectives')->t('Landing page'));
+ } else {
+ // Index page - use parent folder name as title
+ $pageInfo->setTitle(basename($filePath));
+ }
+ } else {
+ $pageInfo->setTitle($fileInfo->getTitle());
+ }
+
+ if ($lastUserId !== null) {
+ $pageInfo->setLastUserId($lastUserId);
+ }
+ if ($lastUserDisplayName !== null) {
+ $pageInfo->setLastUserDisplayName($lastUserDisplayName);
+ }
+ if ($emoji !== null) {
+ $pageInfo->setEmoji($emoji);
+ }
+ if ($fullWidth !== null) {
+ $pageInfo->setFullWidth($fullWidth);
+ }
+ if ($subpageOrder !== null) {
+ $pageInfo->setSubpageOrder($subpageOrder);
+ }
+ if ($slug !== null) {
+ $pageInfo->setSlug($slug);
+ }
+ if ($tags !== null) {
+ $pageInfo->setTags($tags);
+ }
+ if ($linkedPageIds !== null) {
+ $pageInfo->setLinkedPageIds($linkedPageIds);
+ }
+ $pageInfo->setParentId($parentId);
+
+ return $pageInfo;
+ }
+
/**
* @throws InvalidPathException
* @throws NotFoundException
diff --git a/lib/Service/CollectiveService.php b/lib/Service/CollectiveService.php
index f62df7e6c..2f068bf9f 100644
--- a/lib/Service/CollectiveService.php
+++ b/lib/Service/CollectiveService.php
@@ -244,6 +244,7 @@ public function createCollective(string $userId,
$page = new Page();
$page->setFileId($file->getId());
+ $page->setCollectiveId($collective->getId());
$page->setLastUserId($userId);
$this->pageMapper->updateOrInsert($page);
} catch (FilesNotFoundException|InvalidPathException $e) {
diff --git a/lib/Service/PageService.php b/lib/Service/PageService.php
index 8a177b2ae..84950d258 100644
--- a/lib/Service/PageService.php
+++ b/lib/Service/PageService.php
@@ -11,13 +11,16 @@
use Exception;
use OCA\Collectives\Db\Collective;
+use OCA\Collectives\Db\FileCacheMapper;
use OCA\Collectives\Db\Page;
use OCA\Collectives\Db\PageLinkMapper;
use OCA\Collectives\Db\PageMapper;
use OCA\Collectives\Db\TagMapper;
use OCA\Collectives\Fs\NodeHelper;
use OCA\Collectives\Fs\UserFolderHelper;
+use OCA\Collectives\Model\FileInfo;
use OCA\Collectives\Model\PageInfo;
+use OCA\Collectives\Mount\CollectiveFolderManager;
use OCA\Collectives\Trash\PageTrashBackend;
use OCA\NotifyPush\Queue\IQueue;
use OCP\App\IAppManager;
@@ -39,13 +42,15 @@ class PageService {
private ?IQueue $pushQueue = null;
private ?Collective $collective = null;
private ?PageTrashBackend $trashBackend = null;
- private ?array $allPageInfos = null;
+ private bool $fromCollectives = false;
public function __construct(
private readonly IAppManager $appManager,
private readonly PageMapper $pageMapper,
+ private readonly FileCacheMapper $fileCacheMapper,
private readonly NodeHelper $nodeHelper,
private readonly CollectiveServiceBase $collectiveService,
+ private readonly CollectiveFolderManager $collectiveFolderManager,
private readonly UserFolderHelper $userFolderHelper,
private readonly IUserManager $userManager,
ContainerInterface $container,
@@ -60,6 +65,20 @@ public function __construct(
}
}
+ /**
+ * Check if the current operation originates from Collectives
+ */
+ public function isFromCollectives(): bool {
+ return $this->fromCollectives;
+ }
+
+ /**
+ * Set flag to indicate that the operation originates from Collectives
+ */
+ public function setFromCollectives(bool $value): void {
+ $this->fromCollectives = $value;
+ }
+
private function initTrashBackend(): void {
if ($this->appManager->isEnabledForUser('files_trashbin')) {
$this->trashBackend = Server::get(PageTrashBackend::class);
@@ -292,23 +311,30 @@ private function notifyPush(array $body): void {
}
}
- private function updatePage(int $collectiveId, int $fileId, string $userId, ?string $emoji = null, ?bool $fullWidth = null, ?string $slug = null, ?string $tags = null): void {
+ public function updatePage(int $collectiveId, int $fileId, ?string $userId = null, ?string $emoji = null, ?bool $fullWidth = null, ?string $title = null, ?string $tags = null, ?string $slug = null): Page {
$page = new Page();
$page->setFileId($fileId);
- $page->setLastUserId($userId);
+ $page->setCollectiveId($collectiveId);
+ if ($userId !== null) {
+ $page->setLastUserId($userId);
+ }
if ($emoji !== null) {
$page->setEmoji($emoji);
}
if ($fullWidth !== null) {
$page->setFullWidth($fullWidth);
}
- if ($slug !== null) {
+ if ($title !== null) {
+ $page->setSlug($this->slugger->slug($title)->toString());
+ } elseif ($slug !== null) {
$page->setSlug($slug);
}
if ($tags !== null) {
$page->setTags($tags);
}
$this->pageMapper->updateOrInsert($page);
+
+ return $page;
}
/**
@@ -342,10 +368,13 @@ private function updateTags(int $collectiveId, int $fileId, string $userId, stri
* @throws NotPermittedException
*/
private function newPage(int $collectiveId, Folder $folder, string $filename, string $userId, ?string $title, ?string $content = null): PageInfo {
+ $this->setFromCollectives(true);
try {
$newFile = $folder->newFile($filename . PageInfo::SUFFIX, $content);
} catch (FilesNotPermittedException $e) {
throw new NotPermittedException($e->getMessage(), 0, $e);
+ } finally {
+ $this->setFromCollectives(false);
}
$pageInfo = new PageInfo();
@@ -354,9 +383,8 @@ private function newPage(int $collectiveId, Folder $folder, string $filename, st
$this->getParentPageId($newFile),
$userId,
$this->userManager->getDisplayName($userId));
- $slug = $title ? $this->slugger->slug($title)->toString() : null;
- $this->updatePage($collectiveId, $newFile->getId(), $userId, null, null, $slug);
- $pageInfo->setSlug($slug);
+ $page = $this->updatePage($collectiveId, $newFile->getId(), $userId, null, null, $title);
+ $pageInfo->setSlug($page->getSlug());
} catch (FilesNotFoundException|InvalidPathException $e) {
throw new NotFoundException($e->getMessage(), 0, $e);
}
@@ -373,12 +401,15 @@ public function initSubFolder(File $file): Folder {
return $folder;
}
+ $this->setFromCollectives(true);
try {
$folderName = NodeHelper::generateFilename($folder, basename($file->getName(), PageInfo::SUFFIX));
$subFolder = $folder->newFolder($folderName);
$file->move($subFolder->getPath() . '/' . PageInfo::INDEX_PAGE_TITLE . PageInfo::SUFFIX);
} catch (InvalidPathException|FilesNotFoundException|FilesNotPermittedException|LockedException $e) {
throw new NotPermittedException($e->getMessage(), 0, $e);
+ } finally {
+ $this->setFromCollectives(false);
}
return $subFolder;
}
@@ -493,6 +524,7 @@ public function getPagesFromFolder(int $collectiveId, Folder $folder, string $us
return array_merge([$indexPage], $folderPageInfos, $subPageInfos);
}
+ // fixme: provide faster version findChildrenV2 that uses non-recursive single-query loading
/**
* @throws MissingDependencyException
* @throws NotFoundException
@@ -513,22 +545,111 @@ public function findChildren(int $collectiveId, int $parentId, string $userId):
}
}
+ /**
+ * Get all pages for a collective using non-recursive single-query loading
+ */
+ public function getPagesForCollectiveId(int $collectiveId, Folder $folder, string $userId): array {
+ $collectiveFolderPath = $this->collectiveFolderManager->getRootPath() . '/' . $collectiveId;
+
+ // Get collectivePath from mount point (e.g. ".Collectives/my collective")
+ $mountParts = explode('/', $folder->getMountPoint()->getMountPoint(), 4);
+ $collectiveMountPath = (count($mountParts) >= 4) ? rtrim($mountParts[3], '/') : null;
+
+ // Load all pages for this collective in a single query
+ $pages = $this->pageMapper->findByCollectiveId($collectiveId);
+ if (empty($pages)) {
+ $indexPage = $this->newPage($collectiveId, $folder, PageInfo::INDEX_PAGE_TITLE, $userId, PageInfo::INDEX_PAGE_TITLE);
+ return [$indexPage];
+ }
+
+ $fileIds = array_map(static fn (Page $page) => $page->getFileId(), $pages);
+ $pageIds = array_map(static fn (Page $page) => $page->getId(), $pages);
+ $fileInfos = $this->fileCacheMapper->findByFileIds($fileIds);
+ $linkedPageIdsPerPageId = $this->pageLinkMapper->findByPageIds($pageIds);
+
+ // Build path-to-fileId index for parent lookup
+ $indexPageByPath = [];
+ foreach ($fileInfos as $fileInfo) {
+ if ($fileInfo->isIndexPage()) {
+ $indexPageByPath[dirname($fileInfo->path)] = $fileInfo->fileId;
+ }
+ }
+
+ // Build PageInfo objects
+ $pageInfos = [];
+ foreach ($pages as $page) {
+ $fileId = $page->getFileId();
+ if (!isset($fileInfos[$fileId])) {
+ continue;
+ }
+
+ $fileInfo = $fileInfos[$fileId];
+ if ($fileInfo->isInHiddenFolder($collectiveFolderPath)) {
+ continue;
+ }
+
+ $parentId = $this->calculateParentId($fileInfo, $indexPageByPath, $collectiveFolderPath);
+
+ $pageInfo = PageInfo::fromFileInfo(
+ $fileInfo,
+ $parentId,
+ $fileInfo->getRelativePath($collectiveFolderPath),
+ $page->getLastUserId(),
+ $page->getLastUserId() ? $this->userManager->getDisplayName($page->getLastUserId()) : null,
+ $page->getEmoji(),
+ $page->getSubpageOrder(),
+ $page->getFullWidth(),
+ $page->getSlug(),
+ $page->getTags(),
+ $linkedPageIdsPerPageId[$page->getId()] ?? []
+ );
+
+ if ($collectiveMountPath !== null) {
+ $pageInfo->setCollectivePath($collectiveMountPath);
+ }
+
+ $pageInfos[] = $pageInfo;
+ }
+
+ return $pageInfos;
+ }
+
+
+ /**
+ * Calculate parent page ID for a file using path-based lookup
+ *
+ * @param FileInfo $fileInfo
+ * @param array $indexPageByPath Map of folder path => index page fileId
+ * @param string $collectiveFolderPath Collective folder filecache path
+ * @return int
+ */
+ private function calculateParentId(FileInfo $fileInfo, array $indexPageByPath, string $collectiveFolderPath): int {
+ $fileDir = dirname($fileInfo->path);
+
+ if ($fileInfo->isIndexPage()) {
+ // Landing page (index page in collective root) has parent 0
+ if ($fileDir === $collectiveFolderPath) {
+ return 0;
+ }
+
+ // For other index pages, parent is the index page of the grandparent folder
+ $grandparentDir = dirname($fileDir);
+ return $indexPageByPath[$grandparentDir] ?? 0;
+ }
+
+ // For regular pages, parent is the index page in the same folder
+ return $indexPageByPath[$fileDir] ?? 0;
+ }
+
/**
* @throws MissingDependencyException
* @throws NotFoundException
* @throws NotPermittedException
*/
public function findAll(int $collectiveId, string $userId): array {
- if ($this->allPageInfos === null || $this->collective->getId() !== $collectiveId) {
- $folder = $this->getCollectiveFolder($collectiveId, $userId);
- try {
- $this->allPageInfos = $this->getPagesFromFolder($collectiveId, $folder, $userId, true, true);
- } catch (FilesNotFoundException $e) {
- throw new NotFoundException($e->getMessage(), 0, $e);
- }
- }
+ $folder = $this->getCollectiveFolder($collectiveId, $userId);
- return $this->allPageInfos;
+ return $this->getPagesForCollectiveId($collectiveId, $folder, $userId);
}
/**
@@ -823,7 +944,7 @@ private function copySubpagesMetadata(int $collectiveId, Folder $sourceFolder, F
if ($targetNode instanceof File && NodeHelper::isPage($targetNode)) {
$sourcePageInfo = $this->getPageByFile($sourceNode);
$tags = $copyTags ? $sourcePageInfo->getTags() : '[]';
- $this->updatePage($collectiveId, $targetNode->getId(), $userId, $sourcePageInfo->getEmoji(), $sourcePageInfo->isFullWidth(), $sourcePageInfo->getSlug(), $tags);
+ $this->updatePage($collectiveId, $targetNode->getId(), $userId, $sourcePageInfo->getEmoji(), $sourcePageInfo->isFullWidth(), null, $tags, $sourcePageInfo->getSlug());
}
} catch (FilesNotFoundException) {
// Ignore if target node doesn't exist
@@ -881,6 +1002,7 @@ private function moveOrCopyPage(Folder $collectiveFolder, File $file, int $paren
return null;
}
+ $this->setFromCollectives(true);
try {
if ($copy) {
$newNode = $node->copy($newFolder->getPath() . '/' . $newFileName . $suffix);
@@ -891,6 +1013,8 @@ private function moveOrCopyPage(Folder $collectiveFolder, File $file, int $paren
throw new NotFoundException($e->getMessage(), 0, $e);
} catch (FilesNotPermittedException $e) {
throw new NotPermittedException($e->getMessage(), 0, $e);
+ } finally {
+ $this->setFromCollectives(false);
}
if ($newNode instanceof Folder) {
@@ -929,9 +1053,8 @@ public function copy(int $collectiveId, int $id, ?int $parentId, ?string $title,
}
$newPageInfo = $this->getPageByFile($newFile);
- $slug = $this->slugger->slug($title ?: $newPageInfo->getTitle())->toString();
try {
- $this->updatePage($collectiveId, $newFile->getId(), $userId, $pageInfo->getEmoji(), $pageInfo->isFullWidth(), $slug, $pageInfo->getTags());
+ $this->updatePage($collectiveId, $newFile->getId(), $userId, $pageInfo->getEmoji(), $pageInfo->isFullWidth(), $title ?: $newPageInfo->getTitle(), $pageInfo->getTags());
} catch (InvalidPathException|FilesNotFoundException $e) {
throw new NotFoundException($e->getMessage(), 0, $e);
}
@@ -961,9 +1084,8 @@ public function move(int $collectiveId, int $id, ?int $parentId, ?string $title,
}
$newPageInfo = $this->getPageByFile($newFile);
- $slug = $this->slugger->slug($newPageInfo->getTitle())->toString();
try {
- $this->updatePage($collectiveId, $newFile->getId(), $userId, null, null, $slug);
+ $this->updatePage($collectiveId, $newFile->getId(), $userId, null, null, $newPageInfo->getTitle());
} catch (InvalidPathException|FilesNotFoundException $e) {
throw new NotFoundException($e->getMessage(), 0, $e);
}
@@ -1009,9 +1131,8 @@ public function copyToCollective(int $collectiveId, int $id, int $newCollectiveI
}
$newPageInfo = $this->getPageByFile($newFile);
- $slug = $this->slugger->slug($newPageInfo->getTitle())->toString();
try {
- $this->updatePage($newCollectiveId, $newFile->getId(), $userId, $pageInfo->getEmoji(), $pageInfo->isFullWidth(), $slug, '[]');
+ $this->updatePage($newCollectiveId, $newFile->getId(), $userId, $pageInfo->getEmoji(), $pageInfo->isFullWidth(), $newPageInfo->getTitle(), '[]');
} catch (InvalidPathException|FilesNotFoundException $e) {
throw new NotFoundException($e->getMessage(), 0, $e);
}
@@ -1042,9 +1163,8 @@ public function moveToCollective(int $collectiveId, int $id, int $newCollectiveI
}
$newPageInfo = $this->getPageByFile($newFile);
- $slug = $this->slugger->slug($newPageInfo->getTitle())->toString();
try {
- $this->updatePage($newCollectiveId, $newFile->getId(), $userId, null, null, $slug, '[]');
+ $this->updatePage($newCollectiveId, $newFile->getId(), $userId, null, null, $newPageInfo->getTitle(), '[]');
} catch (InvalidPathException|FilesNotFoundException $e) {
throw new NotFoundException($e->getMessage(), 0, $e);
}
@@ -1206,6 +1326,7 @@ public function trash(int $collectiveId, int $id, string $userId, bool $direct =
$pageInfo = $this->getPageByFile($file);
$parentId = $this->getParentPageId($file);
+ $this->setFromCollectives(true);
try {
if (NodeHelper::isIndexPage($file)) {
// Delete folder if it's an index page without subpages
@@ -1218,6 +1339,8 @@ public function trash(int $collectiveId, int $id, string $userId, bool $direct =
throw new NotFoundException($e->getMessage(), 0, $e);
} catch (FilesNotPermittedException $e) {
throw new NotPermittedException($e->getMessage(), 0, $e);
+ } finally {
+ $this->setFromCollectives(false);
}
$this->initTrashBackend();
diff --git a/tests/Integration/features/mountpoint.feature b/tests/Integration/features/mountpoint.feature
index b5eaefee2..f6607feb4 100644
--- a/tests/Integration/features/mountpoint.feature
+++ b/tests/Integration/features/mountpoint.feature
@@ -98,3 +98,11 @@ Feature: mountpoint
Scenario: Trash and delete collective and team
Then user "jane" trashes and deletes collective "BehatMountPoint"
+
+ # fixme: cover scenarios - created file -> page created with collective id
+ # deleted file -> page deleted
+ # copied file to same collective -> collective id set, slug generated
+ # copied file to another collective -> collecvive id set
+ # moved file to another collective -> update collective id
+ # moved file to another collective with renaming -> update collective id and slug
+ # moved out of collective -> delete page
diff --git a/tests/Unit/Fs/NodeHelperTest.php b/tests/Unit/Fs/NodeHelperTest.php
index e8d211da9..b2d492fe9 100644
--- a/tests/Unit/Fs/NodeHelperTest.php
+++ b/tests/Unit/Fs/NodeHelperTest.php
@@ -233,4 +233,32 @@ public function testHasSubPages(): void {
self::assertEquals(1, NodeHelper::folderHasSubPage($parentFolder, 'File2'));
self::assertEquals(2, NodeHelper::folderHasSubPage($folder, 'subfolder'));
}
+
+ public function extractCollectiveIdFromPathProvider(): \Generator {
+ yield 'normal collective page' => ['appdata_abc123/collectives/42/page.md', 42];
+ yield 'collective page in subfolder' => ['appdata_xyz/collectives/123/subfolder/page.md', 123];
+ yield 'collective index page' => ['appdata_test/collectives/1/Readme.md', 1];
+ yield 'trashed collective page' => ['appdata_abc123/collectives/trash/99/page.md', 99];
+ yield 'trashed page in subfolder' => ['appdata_xyz/collectives/trash/456/deleted.md', 456];
+ yield 'non-appdata files path' => ['files/user/docs/page.md', null];
+ yield 'non-appdata groupfolders path' => ['__groupfolders/1/page.md', null];
+ yield 'appdata but non-collectives files' => ['appdata_abc123/files/42/page.md', null];
+ yield 'appdata but non-collectives preview' => ['appdata_abc123/preview/42/page.md', null];
+ yield 'missing collective ID (trailing slash)' => ['appdata_abc123/collectives/', null];
+ yield 'missing collective ID (no trailing slash)' => ['appdata_abc123/collectives', null];
+ yield 'trash missing ID (trailing slash)' => ['appdata_abc123/collectives/trash/', null];
+ yield 'trash missing ID (no trailing slash)' => ['appdata_abc123/collectives/trash', null];
+ yield 'non-numeric collective ID (abc)' => ['appdata_abc123/collectives/abc/page.md', null];
+ yield 'non-numeric collective ID (12_34)' => ['appdata_abc123/collectives/12_34/page.md', null];
+ yield 'non-numeric trash ID' => ['appdata_abc123/collectives/trash/abc/page.md', null];
+ yield 'empty path' => ['', null];
+ yield 'appdata only without rest' => ['appdata_only', null];
+ }
+
+ /**
+ * @dataProvider extractCollectiveIdFromPathProvider
+ */
+ public function testExtractCollectiveIdFromPath(string $path, ?int $expected): void {
+ self::assertEquals($expected, NodeHelper::extractCollectiveIdFromPath($path));
+ }
}
diff --git a/tests/Unit/Service/PageServiceTest.php b/tests/Unit/Service/PageServiceTest.php
index 7cd6a9491..6a37c101b 100644
--- a/tests/Unit/Service/PageServiceTest.php
+++ b/tests/Unit/Service/PageServiceTest.php
@@ -13,6 +13,7 @@
use OC\Files\Mount\MountPoint;
use OCA\Circles\Model\Member;
use OCA\Collectives\Db\Collective;
+use OCA\Collectives\Db\FileCacheMapper;
use OCA\Collectives\Db\Page;
use OCA\Collectives\Db\PageLinkMapper;
use OCA\Collectives\Db\PageMapper;
@@ -57,6 +58,8 @@ protected function setUp(): void {
$this->pageMapper->method('findByFileId')
->willReturn(null);
+ $fileCacheMapper = $this->createMock(FileCacheMapper::class);
+
$this->nodeHelper = $this->getMockBuilder(NodeHelper::class)
->disableOriginalConstructor()
->getMock();
@@ -103,6 +106,7 @@ protected function setUp(): void {
$this->service = new PageService(
$appManager,
$this->pageMapper,
+ $fileCacheMapper,
$this->nodeHelper,
$this->collectiveService,
$userFolderHelper,