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,