diff --git a/REUSE.toml b/REUSE.toml index 7a9b44fec..4c57a73da 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -12,21 +12,21 @@ SPDX-FileCopyrightText = "none" SPDX-License-Identifier = "CC0-1.0" [[annotations]] -path = ["cypress/fixtures/**", "openapi.json", "tests/phpunit.xml", "tests/psalm-baseline.xml", "tests/Integration/features/fixtures/**", "vendor-bin/**"] +path = ["cypress/fixtures/**", "openapi.json", "playwright/support/fixtures/files/**", "tests/phpunit.xml", "tests/psalm-baseline.xml", "tests/Integration/features/fixtures/**", "vendor-bin/**"] precedence = "aggregate" -SPDX-FileCopyrightText = "2020-2024 Nextcloud GmbH and Nextcloud contributors" +SPDX-FileCopyrightText = "2020-2026 Nextcloud GmbH and Nextcloud contributors" SPDX-License-Identifier = "AGPL-3.0-or-later" [[annotations]] path = ["docs/archetypes/default.md", "docs/content/**", "docs/static/images/**"] precedence = "aggregate" -SPDX-FileCopyrightText = "2020-2024 Nextcloud GmbH and Nextcloud contributors" +SPDX-FileCopyrightText = "2020-2026 Nextcloud GmbH and Nextcloud contributors" SPDX-License-Identifier = "AGPL-3.0-or-later" [[annotations]] path = ["l10n/**.js", "l10n/**.json", "skeleton/**.md"] precedence = "aggregate" -SPDX-FileCopyrightText = "2020-2024 Nextcloud GmbH and Nextcloud contributors" +SPDX-FileCopyrightText = "2020-2026 Nextcloud GmbH and Nextcloud contributors" SPDX-License-Identifier = "AGPL-3.0-or-later" [[annotations]] diff --git a/appinfo/info.xml b/appinfo/info.xml index de75b3535..c0ffcae8c 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -69,6 +69,7 @@ In your Nextcloud instance, simply navigate to **»Apps«**, find the OCA\Collectives\Command\CreateCollective OCA\Collectives\Command\ExpirePageVersions OCA\Collectives\Command\GenerateSlugs + OCA\Collectives\Command\ImportMarkdownDirectory OCA\Collectives\Command\IndexCollectives OCA\Collectives\Command\PageTrashCleanup OCA\Collectives\Command\PurgeObsoletePages diff --git a/docs/content/administration/_index.md b/docs/content/administration/_index.md index 9e9fedee3..421ee1dfb 100644 --- a/docs/content/administration/_index.md +++ b/docs/content/administration/_index.md @@ -86,3 +86,37 @@ this also allows adding entire groups to collectives. Keep in mind thought that in contrast to teams, groups can only be managed by server admins. + +## Importing existing data + +It's possible to import existing Markdown files with `occ collectives:import:markdown`. + +The command imports Markdown files from a directory as new pages into a collective. After +importing all files, it processes relative links and referenced local attachments in the +Markdown files. It tries to fix links to other pages and uploads referenced attachments when +the source file is found in the import directory. + +Please beware that the command is memory intensive. When importing a directory with many +Markdown files, make sure to increase the PHP memory limit accordingly: + +```shell +php -d memory_limit=G ./occ collectives:import:markdown -c -u /path/to/markdown/files +``` + +Tests show that importing 500 Markdown files without attachments needs around 1.5GB of memory. + +### Importing from Dokuwiki + +The Markdown directory import command (see above) supports to import Markdown files +generated from a Dokuwiki instance and tries to fix relative links to other pages and +upload referenced attachments. + +Importing is tested with Markdown files generated with the [Dokuwiki2Markdown](https://github.com/mm503/Dokuwiki2Markdown) +tool. + +Here's an example how to import from a Dokuwiki instance: + +```shell +/path/to/doku2md.py -d /path/to/dokuwiki/data/pages -T +php -d memory_limit=2G ./occ collectives:import:markdown -c 123 -u alice /path/to/dokuwiki/data/pages +``` diff --git a/lib/Command/ImportMarkdownDirectory.php b/lib/Command/ImportMarkdownDirectory.php new file mode 100644 index 000000000..6c44eaca3 --- /dev/null +++ b/lib/Command/ImportMarkdownDirectory.php @@ -0,0 +1,107 @@ +setName('collectives:import:markdown') + ->setDescription('Import markdown files from a directory to a collective') + ->setHelp('Memory-intensive operation if importing many files. Consider to raise memory limit with `php -d memory_limit=G occ ...`') + ->addArgument('directory', InputArgument::REQUIRED, 'Directory containing markdown files to import') + ->addOption('collective-id', 'c', InputOption::VALUE_REQUIRED, 'Collective ID to import into') + ->addOption('user-id', 'u', InputOption::VALUE_REQUIRED, 'UserId of collective member performing the import') + ->addOption('parent-id', 'p', InputOption::VALUE_REQUIRED, 'Parent page ID for the import (0 for root)', '0'); + parent::configure(); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $collectiveId = (int)$input->getOption('collective-id'); + $directory = $input->getArgument('directory'); + $userId = $input->getOption('user-id'); + $parentId = (int)$input->getOption('parent-id'); + + $progressReporter = new ProgressReporter($output, $output->isVerbose()); + + if ($collectiveId === 0) { + $progressReporter->writeError('Required option missing: --collective-id=COLLECTIVE_ID'); + return 1; + } + + if ($userId === null) { + $progressReporter->writeError('Required option missing: --user-id=USER_ID'); + return 1; + } + + // Verify user exists + $user = $this->userManager->get($userId); + if (!$user) { + $progressReporter->writeError('User ' . $userId . ' not found'); + return 1; + } + + // Verify collective exists + try { + $collective = $this->collectiveService->getCollective($collectiveId, $userId); + } catch (NotFoundException $e) { + if (str_starts_with($e->getMessage(), 'Circle not found')) { + $progressReporter->writeError('Collective with ID ' . $collectiveId . ' not accessible for user ' . $userId); + } else { + $progressReporter->writeError('Collective with ID ' . $collectiveId . ' not found'); + } + return 1; + } + + $importService = new ImportService( + $this->pageService, + $this->attachmentService, + $this->mimeTypeDetector, + $progressReporter, + $collective, + $user, + ); + + try { + $importService->importDirectory($directory, $parentId); + } catch (NotFoundException $e) { + $progressReporter->writeError($e->getMessage()); + return 1; + } + + $progressReporter->writeInfo(''); + $progressReporter->writeInfo('Processed ' . $importService->getCount() . ' file(s) for collective "' . $collective->getName() . '" (ID: ' . $collectiveId . ').'); + + return 0; + } +} diff --git a/lib/Command/IndexCollectives.php b/lib/Command/IndexCollectives.php index 071f06e2a..d39a8fd0c 100644 --- a/lib/Command/IndexCollectives.php +++ b/lib/Command/IndexCollectives.php @@ -57,10 +57,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int $output->writeln('done'); } catch (MissingDependencyException|NotFoundException|NotPermittedException) { $output->writeln("Failed to find team associated with collective with ID={$collective->getId()}"); - return 1; + continue; } catch (FileSearchException) { $output->writeln('Failed to save the indices to the collectives folder.'); - return 1; + continue; } } diff --git a/lib/Fs/MarkdownHelper.php b/lib/Fs/MarkdownHelper.php index c4b8b6c62..838600407 100644 --- a/lib/Fs/MarkdownHelper.php +++ b/lib/Fs/MarkdownHelper.php @@ -12,9 +12,11 @@ use League\CommonMark\Environment\Environment; use League\CommonMark\Exception\CommonMarkException; use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension; +use League\CommonMark\Extension\CommonMark\Node\Inline\Image; use League\CommonMark\Extension\CommonMark\Node\Inline\Link; use League\CommonMark\Node\Inline\Text; use League\CommonMark\Node\Node; +use League\CommonMark\Node\NodeWalker; use League\CommonMark\Parser\MarkdownParser; use OC; use OCA\Collectives\Db\Collective; @@ -36,17 +38,21 @@ private static function collectText(Node $node): string { return $out; } + private static function getDocumentWalker(string $content): NodeWalker { + $environment = new Environment(); + $environment->addExtension(new CommonMarkCoreExtension()); + $parser = new MarkdownParser($environment); + $document = $parser->parse($content); + return $document->walker(); + } + /** - * Extracts markdown links and returns them with link text, href and title + * Extracts Markdown links and returns them with link text, href and title * * @throws CommonMarkException */ public static function getLinksFromContent(string $content): array { - $environment = new Environment(); - $environment->addExtension(new CommonMarkCoreExtension()); - $parser = new MarkdownParser($environment); - $document = $parser->parse($content); - $walker = $document->walker(); + $walker = self::getDocumentWalker($content); $links = []; while ($event = $walker->next()) { @@ -74,6 +80,40 @@ public static function getLinksFromContent(string $content): array { return $links; } + /** + * Extracts Markdown images and returns them with alt text, url and title + * + * @throws CommonMarkException + */ + public static function getImageLinksFromContent(string $content): array { + $walker = self::getDocumentWalker($content); + + $images = []; + while ($event = $walker->next()) { + if (! $event->isEntering()) { + continue; + } + $node = $event->getNode(); + if (!($node instanceof Image)) { + continue; + } + + $altTextParts = []; + foreach ($node->children() as $child) { + $altTextParts[] = self::collectText($child); + } + $altText = trim(implode('', array_filter($altTextParts))); + + $images[] = [ + 'alt' => $altText, + 'url' => $node->getUrl(), + 'title' => $node->getTitle() ?? '', + ]; + } + + return $images; + } + /** * Returns hrefs that point to given collective or are relative links (.e.g. `../Page-21`) */ @@ -160,4 +200,25 @@ public static function getLinkedPageIds(Collective $collective, string $content, return array_unique($pageIds, SORT_NUMERIC); } + + /** + * Replace callout syntax `:!: ` with ours (`::: warning\n\n\n:::`) + */ + public static function processCallouts(string $content): string { + $lines = explode("\n", $content); + $result = []; + + foreach ($lines as &$line) { + if (preg_match('/^:!:\s+(.+)$/', $line, $matches)) { + $calloutText = trim($matches[1]); + $result[] = '::: warn'; + $result[] = $calloutText; + $result[] = ''; + $result[] = ':::'; + } else { + $result[] = $line; + } + } + return implode("\n", $result); + } } diff --git a/lib/Service/AttachmentService.php b/lib/Service/AttachmentService.php index aa9fba4f6..b239bdfe5 100644 --- a/lib/Service/AttachmentService.php +++ b/lib/Service/AttachmentService.php @@ -23,6 +23,7 @@ class AttachmentService { private ?PageTrashBackend $trashBackend = null; + private array $attachmentDirectory = []; public function __construct( private readonly IAppManager $appManager, @@ -63,21 +64,29 @@ private function fileToInfo(File $file, folder $folder, string $type = 'text'): * @throws NotFoundException */ private function getAttachmentDirectory(File $pageFile, bool $create = false): Folder { - try { - $parentFolder = $pageFile->getParent(); - $attachmentFolderName = '.attachments.' . $pageFile->getId(); - if ($parentFolder->nodeExists($attachmentFolderName)) { - $attachmentFolder = $parentFolder->get($attachmentFolderName); - if ($attachmentFolder instanceof Folder) { - return $attachmentFolder; + $id = $pageFile->getId(); + if (!isset($this->attachmentDirectory[$id])) { + try { + $parentFolder = $pageFile->getParent(); + $attachmentFolderName = '.attachments.' . $id; + if ($parentFolder->nodeExists($attachmentFolderName)) { + $attachmentFolder = $parentFolder->get($attachmentFolderName); + if ($attachmentFolder instanceof Folder) { + $this->attachmentDirectory[$id] = $attachmentFolder; + } + } elseif ($create) { + $this->attachmentDirectory[$id] = $parentFolder->newFolder($attachmentFolderName); } - } elseif ($create) { - return $parentFolder->newFolder($attachmentFolderName); + } catch (FilesNotFoundException|InvalidPathException) { + throw new NotFoundException('Failed to get attachment directory for page ' . $id . '.'); + } + + if (!isset($this->attachmentDirectory[$id])) { + throw new NotFoundException('Attachment directory for page ' . $id . ' does not exist.'); } - } catch (FilesNotFoundException|InvalidPathException) { - throw new NotFoundException('Failed to get attachment directory for page ' . $pageFile->getId() . '.'); } - throw new NotFoundException('Failed to get attachment directory for page ' . $pageFile->getId() . '.'); + + return $this->attachmentDirectory[$id]; } private function getTextAttachments(File $pageFile, Folder $folder): array { @@ -208,4 +217,16 @@ public function restoreAttachment(int $collectiveId, File $pageFile, int $attach return $this->getAttachmentDirectory($pageFile)->getById($attachmentId); } + + /** + * @throws NotFoundException + * @throws NotPermittedException + */ + public function putAttachment(File $pageFile, string $attachmentName, string $content): string { + $attachmentDir = $this->getAttachmentDirectory($pageFile, true); + + $filename = NodeHelper::generateFilename($attachmentDir, $attachmentName); + $attachmentDir->newFile($filename, $content); + return '.attachments.' . $pageFile->getId() . DIRECTORY_SEPARATOR . $filename; + } } diff --git a/lib/Service/IProgressReporter.php b/lib/Service/IProgressReporter.php new file mode 100644 index 000000000..7ff4a52a6 --- /dev/null +++ b/lib/Service/IProgressReporter.php @@ -0,0 +1,20 @@ +count; + } + + public function importDirectory(string $directory, int $parentId): void { + // Verify directory exists and is readable + if (!file_exists($directory) || !is_dir($directory) || !is_readable($directory)) { + throw new NotFoundException('Directory not accessible: ' . $directory); + } + + if ($parentId !== 0) { + // Also verifies that the parentId page exists + $parentPage = $this->pageService->findByFileId($this->collective->getId(), $parentId, $this->user->getUID()); + } else { + $parentPage = null; + } + + $memory = memory_get_usage(); + $this->processDirectory($directory, $parentPage); + $message = sprintf('Memory usage after importing pages: %.2fMB (peak usage: %.2fMB)', + ((float)memory_get_usage() - (float)$memory) / 1024.0 / 1024.0, + (float)memory_get_peak_usage() / 1024.0 / 1024.0); + $this->progressReporter->writeInfoVerbose($message); + $memory = memory_get_usage(); + if ($this->count === 0) { + throw new NotFoundException('No markdown files found in directory: ' . $directory); + } + + // Third pass: rewrite relative links in all imported pages + $this->rewriteInternalLinksAndAttachments($parentId, $directory); + $message = sprintf('Memory usage after rewriting links and attachments: %.2fMB (+%.2fMb, peak usage: %.2fMB)', + (float)memory_get_usage() / 1024.0 / 1024.0, + ((float)memory_get_usage() - (float)$memory) / 1024.0 / 1024.0, + (float)memory_get_peak_usage() / 1024.0 / 1024.0); + $this->progressReporter->writeInfoVerbose($message); + } + + /** + * Recursively import Markdown files from directory + */ + private function processDirectory(string $directory, ?PageInfo $parentPage): void { + // Verify directory exists and is readable + if (!is_readable($directory)) { + $this->progressReporter->writeError(sprintf('✗ Failed: %s - Directory not readable', $directory)); + return; + } + + $parentId = $parentPage !== null ? $parentPage->getId() : 0; + $items = scandir($directory); + if ($items === false) { + throw new NotFoundException('Unable to read directory: ' . $directory); + } + + // First pass: import Markdown files at this level + $mdFiles = array_filter($items, static function ($item) use ($directory) { + return is_file($directory . DIRECTORY_SEPARATOR . $item) && strtolower(pathinfo($item, PATHINFO_EXTENSION)) === 'md'; + }); + foreach ($mdFiles as $item) { + $path = $directory . DIRECTORY_SEPARATOR . $item; + + try { + [$id, $title] = $this->processFile($directory, $item, $parentPage); + $this->fileMap[$path] = $id; + $this->count++; + $this->progressReporter->writeInfo(sprintf('✓ Imported #%d: %s - %s (pageId: %d)', $this->count, $path, $title, $id)); + } catch (NotFoundException $e) { + $this->progressReporter->writeError(sprintf('✗ Failed: %s - %s', $path, $e->getMessage())); + } + } + + // Second pass: import subdirectories + $subDirs = array_filter($items, static function ($item) use ($directory) { + return is_dir($directory . DIRECTORY_SEPARATOR . $item) && $item !== '.' && $item !== '..'; + }); + foreach ($subDirs as $item) { + $path = $directory . DIRECTORY_SEPARATOR . $item; + + $readmeName = self::getReadmeFromDirectory($path); + if ($readmeName !== null) { + // Create index page from readme.md if exists + try { + [$id, $title] = $this->processFile($path, $readmeName, $parentPage, $item); + $this->fileMap[$path . DIRECTORY_SEPARATOR . $readmeName] = $id; + $this->count++; + $this->progressReporter->writeInfo(sprintf('✓ Imported #%d: %s - %s (pageId: %d)', $this->count, $path . DIRECTORY_SEPARATOR . $readmeName, $title, $id)); + } catch (NotFoundException $e) { + $this->progressReporter->writeError(sprintf('✗ Failed: %s - %s', $path, $e->getMessage())); + continue; + } + $indexPageInfo = $this->pageService->findByFileId($this->collective->getId(), $id, $this->user->getUID()); + } else { + // Create new empty index page + $indexPageInfo = $this->pageService->getOrCreate($this->collective->getId(), $parentId, $item, $this->user->getUID()); + } + $this->processDirectory($path, $indexPageInfo); + } + } + + private function processFile(string $directory, string $item, ?PageInfo $parentPage, ?string $title = null): array { + $path = $directory . DIRECTORY_SEPARATOR . $item; + $parentId = $parentPage !== null ? $parentPage->getId() : 0; + $title = $title ?? basename($path, '.md'); + if (!is_readable($path)) { + throw new NotFoundException('File not readable'); + } + + $mimeType = $this->mimeTypeDetector->detectPath($path); + if (!in_array($mimeType, ['text/markdown', 'text/plain'], true)) { + throw new NotFoundException('Invalid mime type: ' . $mimeType); + } + + $content = file_get_contents($path); + if ($content === false) { + throw new NotFoundException('Failed to read file content'); + } + + $content = MarkdownHelper::processCallouts($content); + + try { + if (strtolower($title) === 'readme' && $parentId === 0) { + // Special case: use parent directory name as title for README.md files + $title = basename($directory); + $parentId = $parentPage !== null ? $parentPage->getParentId() : 0; + } + $pageInfo = $this->pageService->createBase( + $this->collective->getId(), + $parentId, + $title, + null, + $this->user->getUID(), + null, + $content, + ); + } catch (NotFoundException|NotPermittedException $e) { + throw new NotFoundException('Failed to create page: ' . $e->getMessage()); + } + + $id = $pageInfo->getId(); + $title = $pageInfo->getTitle(); + + // Free some memory + unset($content, $pageInfo); + gc_collect_cycles(); + + return [$id, $title]; + } + + /** + * Rewrite relative links in all imported pages to point to new page URLs + */ + private function rewriteInternalLinksAndAttachments(int $parentId, string $baseDirectory): void { + foreach ($this->fileMap as $filePath => $pageId) { + try { + $pageFile = $this->pageService->getPageFile($this->collective->getId(), $pageId, $this->user->getUID()); + $content = $pageFile->getContent(); + } catch (NotFoundException|NotPermittedException $e) { + $this->progressReporter->writeError(sprintf('✗ Failed: %s - Failed to read page content: %s', $filePath, $e->getMessage())); + continue; + } + + $updatedContent = $content; + + $links = MarkdownHelper::getLinksFromContent($content); + $linkCount = 0; + foreach ($links as $link) { + $linkCount += $this->processLink($link, $parentId, $filePath, $updatedContent); + } + + $attachments = MarkdownHelper::getImageLinksFromContent($content); + $attachmentCount = 0; + foreach ($attachments as $attachment) { + $attachmentCount += $this->processAttachment($attachment, $pageFile, $filePath, $baseDirectory, $updatedContent); + } + + $updateCount = $linkCount + $attachmentCount; + if ($updateCount > 0) { + NodeHelper::putContent($pageFile, $updatedContent); + $this->progressReporter->writeInfo(sprintf('🔗 %d links and attachments updated: %s', $updateCount, $filePath)); + } + } + } + + private static function sanitizeHref(string $href): ?string { + // Only process relative links (not absolute URLs or root-relative paths) + if (!$href || str_starts_with($href, '/') || preg_match('/^[a-zA-Z]+:\/\//', $href)) { + return null; + } + + // Ignore mailto links + if (str_starts_with($href, 'mailto:')) { + return null; + } + + // Remove fragment and query string from link + $sanitizedHref = preg_replace('/[?#].*$/', '', $href); + + // Remove `./` prefix from relative link + if (str_starts_with($sanitizedHref, './')) { + $sanitizedHref = substr($sanitizedHref, 2); + } + + return $sanitizedHref; + } + + /** + * Dokuwiki2Markdown generates links with colons instead of paths + * Converts `:topic:subtopic:page` to `topic/subtopic/page` + * Converts `:media:topic:image.png?400` to `media/topic/image.png` + */ + private static function getDokuwikiHref(string $href): string { + // Remove leading ':' if existent + if (str_starts_with($href, ':')) { + $href = substr($href, 1); + } + + // Remove query string (e.g. image size in attachments) if present + $href = preg_replace('/[?#].*$/', '', $href); + + // Replace colons with directory separators to get the actual path + return str_replace(':', DIRECTORY_SEPARATOR, $href); + } + + private function processLink(array $link, int $parentId, string $filePath, string &$updatedContent): int { + $href = $link['href']; + $sanitizedHref = self::sanitizeHref($href); + if ($sanitizedHref === null) { + return 0; + } + + $candidates = []; + + // Consider link with and without .md extension + if (str_ends_with($sanitizedHref, '.md')) { + $candidates[] = $sanitizedHref; + $candidates[] = substr($sanitizedHref, 0, -3); // without .md + } else { + $candidates[] = $sanitizedHref; + $candidates[] = $sanitizedHref . '.md'; + } + + // E.g. Dokuwiki2Markdown generates links where pages are separated with colons + if (str_contains($sanitizedHref, ':')) { + $dokuwikiPath = self::getDokuwikiHref($sanitizedHref); + $candidates[] = $dokuwikiPath; + + // Add additional candidates by stripping leading path segments + // It's an attempt to allow processing links even if a subdirectory gets imported + $pathSegments = explode(DIRECTORY_SEPARATOR, $dokuwikiPath); + for ($i = 1; $i < count($pathSegments); $i++) { + $candidates[] = implode(DIRECTORY_SEPARATOR, array_slice($pathSegments, $i)); + } + } + + // Try to find target page + $targetPageInfo = null; + foreach ($candidates as $candidate) { + try { + $targetPageInfo = $this->pageService->findByPath($this->collective->getId(), $candidate, $this->user->getUID(), $parentId); + break; + } catch (NotFoundException|NotPermittedException) { + } + } + + if ($targetPageInfo === null) { + $this->progressReporter->writeErrorVerbose(sprintf('✗ Failed: %s - Didn\'t find target page for link %s, not updated', $filePath, $href)); + return 0; + } + + $newHref = $this->pageService->getPageLink($this->collective->getUrlPath(), $targetPageInfo); + + // Preserve fragment if present + if (preg_match('/#(.+)$/', $href, $matches)) { + $newHref .= '#' . $matches[1]; + } + + // Replace the link in content + $oldLink = '](' . $href . ')'; + $newLink = '](' . $newHref . ')'; + $updatedContent = str_replace($oldLink, $newLink, $updatedContent); + return 1; + } + + private function processAttachment(array $image, File $pageFile, string $filePath, string $baseDirectory, string &$updatedContent): int { + $url = $image['url']; + if (!$url) { + return 0; + } + $sanitizedHref = self::sanitizeHref($url); + if ($sanitizedHref === null) { + return 0; + } + + $candidates = []; + $candidates[] = $baseDirectory . DIRECTORY_SEPARATOR . $sanitizedHref; + + if (str_contains($sanitizedHref, ':')) { + $dokuwikiPath = self::getDokuwikiHref($sanitizedHref); + $candidates[] = $baseDirectory . DIRECTORY_SEPARATOR . '..' . DIRECTORY_SEPARATOR . 'media' . DIRECTORY_SEPARATOR . $dokuwikiPath; + $candidates[] = $baseDirectory . DIRECTORY_SEPARATOR . 'media' . DIRECTORY_SEPARATOR . $dokuwikiPath; + + // Add additional candidates by stripping leading path segments + // It's an attempt to allow processing attachments even if a subdirectory gets imported + $pathSegments = explode(DIRECTORY_SEPARATOR, $dokuwikiPath); + for ($i = 1; $i < count($pathSegments); $i++) { + $pathPart = implode(DIRECTORY_SEPARATOR, array_slice($pathSegments, $i)); + $candidates[] = $baseDirectory . DIRECTORY_SEPARATOR . 'media' . DIRECTORY_SEPARATOR . $pathPart; + $candidates[] = $baseDirectory . DIRECTORY_SEPARATOR . '..' . DIRECTORY_SEPARATOR . 'media' . DIRECTORY_SEPARATOR . $pathPart; + } + } + + // Try to find linked attachment + $targetAttachment = null; + foreach ($candidates as $candidate) { + $realPath = realpath($candidate); + if ($realPath && is_file($realPath) && is_readable($realPath)) { + $targetAttachment = $realPath; + break; + } + } + + if ($targetAttachment === null) { + $this->progressReporter->writeErrorVerbose(sprintf('✗ Failed: %s - Didn\'t find source file for attachment reference %s, not updated', $filePath, $url)); + return 0; + } + + $newUrl = $this->attachmentService->putAttachment( + $pageFile, + basename($targetAttachment), + file_get_contents($targetAttachment) ?: '', + ); + + // Replace the attachment reference in content + $oldLink = '](' . $url . ')'; + $newLink = '](' . $newUrl . ')'; + $updatedContent = str_replace($oldLink, $newLink, $updatedContent); + return 1; + } + + private static function getReadmeFromDirectory(string $path): ?string { + $items = scandir($path); + if ($items === false) { + return null; + } + + foreach ($items as $item) { + if (strtolower($item) === 'readme.md') { + return $item; + } + } + + return null; + } +} diff --git a/lib/Service/PageService.php b/lib/Service/PageService.php index 6e77d5cee..befd5d737 100644 --- a/lib/Service/PageService.php +++ b/lib/Service/PageService.php @@ -39,6 +39,7 @@ class PageService { private ?IQueue $pushQueue = null; private ?Collective $collective = null; private ?PageTrashBackend $trashBackend = null; + private ?array $allPageInfos = null; public function __construct( private readonly IAppManager $appManager, @@ -345,9 +346,9 @@ private function updateTags(int $collectiveId, int $fileId, string $userId, stri * @throws NotFoundException * @throws NotPermittedException */ - private function newPage(int $collectiveId, Folder $folder, string $filename, string $userId, ?string $title): PageInfo { + private function newPage(int $collectiveId, Folder $folder, string $filename, string $userId, ?string $title, ?string $content = null): PageInfo { try { - $newFile = $folder->newFile($filename . PageInfo::SUFFIX); + $newFile = $folder->newFile($filename . PageInfo::SUFFIX, $content); } catch (FilesNotPermittedException $e) { throw new NotPermittedException($e->getMessage(), 0, $e); } @@ -523,12 +524,16 @@ public function findChildren(int $collectiveId, int $parentId, string $userId): * @throws NotPermittedException */ public function findAll(int $collectiveId, string $userId): array { - $folder = $this->getCollectiveFolder($collectiveId, $userId); - try { - return $this->getPagesFromFolder($collectiveId, $folder, $userId, true, true); - } catch (FilesNotFoundException $e) { - throw new NotFoundException($e->getMessage(), 0, $e); + 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); + } } + + return $this->allPageInfos; } /** @@ -606,10 +611,11 @@ public function findByPath(int $collectiveId, string $path, string $userId, ?int } $parentPageId = $landingPageId; - $allPages = $this->findAll($collectiveId, $userId); + $allPageInfos = $this->findAll($collectiveId, $userId); + $matchingPage = null; foreach (explode('/', $path) as $title) { $matchingPage = null; - foreach ($allPages as $pageInfo) { + foreach ($allPageInfos as $pageInfo) { if ($pageInfo->getTitle() === $title && $pageInfo->getParentId() === $parentPageId) { $matchingPage = $pageInfo; break; @@ -669,22 +675,58 @@ public function findByFile(int $collectiveId, File $file, string $userId): PageI * @throws NotFoundException * @throws NotPermittedException */ - public function create(int $collectiveId, int $parentId, string $title, ?int $templateId, string $userId, ?string $defaultTitle = null): PageInfo { + public function createBase(int $collectiveId, int $parentId, string $title, ?int $templateId, string $userId, ?string $defaultTitle = null, ?string $content = null): PageInfo { $this->verifyEditPermissions($collectiveId, $userId); + if ($parentId === 0) { + $collectiveFolder = $this->getCollectiveFolder($collectiveId, $userId); + $parentId = self::getIndexPageFile($collectiveFolder)->getId(); + } $folder = $this->getFolder($collectiveId, $parentId, $userId); $parentFile = $this->nodeHelper->getFileById($folder, $parentId); $folder = $this->initSubFolder($parentFile); $safeTitle = $this->nodeHelper->sanitiseFilename($title, $defaultTitle ?: self::DEFAULT_PAGE_TITLE); $filename = NodeHelper::generateFilename($folder, $safeTitle, PageInfo::SUFFIX); - $pageInfo = $templateId + return $templateId ? $this->copy($collectiveId, $templateId, $parentId, $safeTitle, 0, $userId) - : $this->newPage($collectiveId, $folder, $filename, $userId, $title); + : $this->newPage($collectiveId, $folder, $filename, $userId, $title, $content); + } + + /** + * @throws MissingDependencyException + * @throws NotFoundException + * @throws NotPermittedException + */ + public function create(int $collectiveId, int $parentId, string $title, ?int $templateId, string $userId, ?string $defaultTitle = null): PageInfo { + $pageInfo = $this->createBase($collectiveId, $parentId, $title, $templateId, $userId, $defaultTitle); $parentPageInfo = $this->addToSubpageOrder($collectiveId, $parentId, $pageInfo->getId(), 0, $userId); $this->notifyPush(['collectiveId' => $collectiveId, 'pages' => [$pageInfo, $parentPageInfo]]); return $pageInfo; } + /** + * @throws MissingDependencyException + * @throws NotFoundException + * @throws NotPermittedException + */ + public function getOrCreate(int $collectiveId, int $parentId, string $title, string $userId): PageInfo { + $folder = $this->getFolder($collectiveId, $parentId, $userId); + $landingPageId = self::getIndexPageFile($folder)->getId(); + try { + $childPages = $this->getPagesFromFolder($collectiveId, $folder, $userId); + } catch (FilesNotFoundException $e) { + throw new NotFoundException($e->getMessage(), 0, $e); + } + foreach ($childPages as $pageInfo) { + if ($pageInfo->getTitle() === $title && $pageInfo->getParentId() === $landingPageId) { + return $pageInfo; + } + } + + $this->verifyEditPermissions($collectiveId, $userId); + return $this->createBase($collectiveId, $parentId, $title, null, $userId); + } + /** * @throws MissingDependencyException * @throws NotFoundException diff --git a/lib/Service/ProgressReporter.php b/lib/Service/ProgressReporter.php new file mode 100644 index 000000000..7244c5389 --- /dev/null +++ b/lib/Service/ProgressReporter.php @@ -0,0 +1,40 @@ +output->writeln('' . $message . ''); + } + + public function writeInfoVerbose(string $message): void { + if ($this->verbose) { + $this->output->writeln('' . $message . ''); + } + } + + public function writeError(string $message): void { + $this->output->writeln('' . $message . ''); + } + + public function writeErrorVerbose(string $message): void { + if ($this->verbose) { + $this->output->writeln('' . $message . ''); + } + } +} diff --git a/playwright/e2e/import-markdown.spec.ts b/playwright/e2e/import-markdown.spec.ts new file mode 100644 index 000000000..0caf3b719 --- /dev/null +++ b/playwright/e2e/import-markdown.spec.ts @@ -0,0 +1,47 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, mergeTests } from '@playwright/test' +import { test as createCollectiveTest } from '../support/fixtures/create-collectives.ts' +import { test as editorTest } from '../support/fixtures/editor.ts' + +const collectiveName = 'ImportMarkdownCollective' + +const collectiveTest = createCollectiveTest.extend({ + // eslint-disable-next-line no-empty-pattern + collectiveConfigs: async ({}, use) => use([ + { name: collectiveName, markdownImportPath: '/var/www/html/apps/collectives/playwright/support/fixtures/files/DokuwikiMarkdownExport/pages' }, + ]), +}) + +const test = mergeTests(collectiveTest, editorTest) + +test.describe('Import Markdown', () => { + test.describe.configure({ mode: 'serial' }) + + test('attached images got rewritten and render', async ({ collective, editor }) => { + await collective.openCollective({ pageTitle: 'start' }) + await expect(collective.page).toHaveTitle(`start - ${collectiveName} - Collectives - Nextcloud`) + await editor.hasImage('stegosaurus.png') + + await collective.openCollective({ pageTitle: 'page1/subpage1' }) + await expect(collective.page).toHaveTitle(`subpage1 - page1 - ${collectiveName} - Collectives - Nextcloud`) + await editor.hasImage('triceratops.png') + }) + + test('table renders', async ({ collective, editor }) => { + await collective.openCollective({ pageTitle: 'page1' }) + await expect(collective.page).toHaveTitle(`page1 - ${collectiveName} - Collectives - Nextcloud`) + await expect(editor.content + .locator('table td:first-child')) + .toHaveText('cell1') + }) + + test('internal links got rewritten and work', async ({ collective, editor }) => { + await collective.openCollective({ pageTitle: 'start' }) + await editor.hasInternalLink('page1') + await editor.hasInternalLink('subpage1') + }) +}) diff --git a/playwright/support/fixtures/Collective.ts b/playwright/support/fixtures/Collective.ts index 32b65ba5f..2cc7f241f 100644 --- a/playwright/support/fixtures/Collective.ts +++ b/playwright/support/fixtures/Collective.ts @@ -4,6 +4,7 @@ */ import { type Page } from '@playwright/test' +import { apiUrl } from './urls.ts' import { type User } from './User.ts' type CollectiveData = { @@ -30,6 +31,12 @@ type CollectiveData = { trashTimestamp?: number } +const ocsHeaders = { + 'OCS-APIRequest': 'true', + Accept: 'application/json', + 'Content-Type': 'application/json', +} + export class Collective { public readonly data: CollectiveData public readonly page: Page @@ -43,20 +50,28 @@ export class Collective { await this.page.goto('/index.php/apps/collectives') } - async openCollective() { + async openCollective({ pageTitle }: { pageTitle?: string } = {}) { const { slug, id, name } = this.data - const path = slug + const collectivePath = slug ? `${slug}-${id}` : encodeURIComponent(name) - await this.page.goto(`/index.php/apps/collectives/${path}`) + let pagePath = '' + if (pageTitle) { + pagePath = `/${pageTitle}` + } + await this.page.goto(`/index.php/apps/collectives/${collectivePath}${pagePath}`) await this.waitForReaderContent() } + getReaderContent() { + return this.page.locator('[data-cy-collectives="reader"] .ProseMirror') + } + /** * Wait for the collective landing page to finish loading. */ async waitForReaderContent() { - await this.page.locator('[data-cy-collectives="reader"] .ProseMirror') + await this.getReaderContent() .waitFor({ state: 'visible' }) } } @@ -75,15 +90,10 @@ export async function createCollective({ name, emoji = '', user }: { emoji?: string user: User }) { - const headers = { - Accept: 'application/json', - 'Content-Type': 'application/json', - 'OCS-APIRequest': 'true', - } const response = await user.request.post( - '/ocs/v2.php/apps/collectives/api/v1.0/collectives', + apiUrl('v1.0', 'collectives'), { - headers, + headers: ocsHeaders, data: { name, emoji, @@ -108,20 +118,17 @@ export async function trashAndDeleteCollective({ id, user }: { id: number user: User }) { - const headers = { - 'OCS-APIRequest': 'true', - } const trashResponse = await user.request.delete( - `/ocs/v2.php/apps/collectives/api/v1.0/collectives/${id}`, - { headers }, + apiUrl('v1.0', 'collectives', id), + { headers: ocsHeaders }, ) if (!trashResponse.ok()) { throw new Error(`Failed to trash collective: ${trashResponse.status()} - ${trashResponse.statusText()}`) } const deleteResponse = await user.request.delete( - `/ocs/v2.php/apps/collectives/api/v1.0/collectives/trash/${id}`, + apiUrl('v1.0', 'collectives', 'trash', id), { - headers, + headers: ocsHeaders, data: { circle: true, }, diff --git a/playwright/support/fixtures/User.ts b/playwright/support/fixtures/User.ts index 4f0a313f1..68919b9ed 100644 --- a/playwright/support/fixtures/User.ts +++ b/playwright/support/fixtures/User.ts @@ -7,7 +7,10 @@ import { type Page } from '@playwright/test' import { createCollective, trashAndDeleteCollective } from './Collective.ts' export class User { - constructor(public readonly page: Page) { + constructor( + public readonly page: Page, + public readonly userId: string, + ) { } get request() { diff --git a/playwright/support/fixtures/create-collectives.ts b/playwright/support/fixtures/create-collectives.ts index 18d87ef5c..1be5caddf 100644 --- a/playwright/support/fixtures/create-collectives.ts +++ b/playwright/support/fixtures/create-collectives.ts @@ -3,11 +3,18 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ +import { runOcc } from '@nextcloud/e2e-test-server/docker' import { type Collective } from './Collective.ts' import { test as base } from './random-user.ts' +export interface CollectiveConfig { + name: string + emoji?: string // optional + markdownImportPath?: string // optional +} + export interface CollectivesFixture { - collectiveConfigs: Array<{ name: string, emoji?: string }> + collectiveConfigs: CollectiveConfig[] collectives: Collective[] collective: Collective } @@ -43,6 +50,17 @@ export const test = base.extend({ for (const config of collectiveConfigs) { const collective = await user.createCollective(config) createdCollectives.push(collective) + + // Import Markdown if path is provided + if (config.markdownImportPath) { + await runOcc([ + 'collectives:import:markdown', + `--collective-id=${collective.data.id}`, + `--user-id=${user.userId}`, + '--', + config.markdownImportPath, + ]) + } } await use(createdCollectives) diff --git a/playwright/support/fixtures/editor.ts b/playwright/support/fixtures/editor.ts new file mode 100644 index 000000000..d5b19422c --- /dev/null +++ b/playwright/support/fixtures/editor.ts @@ -0,0 +1,18 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { test as baseTest } from '@playwright/test' +import { EditorSection } from '../sections/EditorSection.ts' + +interface EditorFixture { + editor: EditorSection +} + +export const test = baseTest.extend({ + editor: async ({ page }, use) => { + const editor = new EditorSection(page) + await use(editor) + }, +}) diff --git a/playwright/support/fixtures/files/DokuwikiMarkdownExport/media/page1/triceratops.png b/playwright/support/fixtures/files/DokuwikiMarkdownExport/media/page1/triceratops.png new file mode 100644 index 000000000..dbee657a0 Binary files /dev/null and b/playwright/support/fixtures/files/DokuwikiMarkdownExport/media/page1/triceratops.png differ diff --git a/playwright/support/fixtures/files/DokuwikiMarkdownExport/media/stegosaurus.png b/playwright/support/fixtures/files/DokuwikiMarkdownExport/media/stegosaurus.png new file mode 100644 index 000000000..b6423b03e Binary files /dev/null and b/playwright/support/fixtures/files/DokuwikiMarkdownExport/media/stegosaurus.png differ diff --git a/playwright/support/fixtures/files/DokuwikiMarkdownExport/pages/page1.md b/playwright/support/fixtures/files/DokuwikiMarkdownExport/pages/page1.md new file mode 100644 index 000000000..481ae05ae --- /dev/null +++ b/playwright/support/fixtures/files/DokuwikiMarkdownExport/pages/page1.md @@ -0,0 +1,7 @@ +# Page1 + +Table: + +| header1 | header2 | +| ------- | ------- | +| cell1 | cell2 | diff --git a/playwright/support/fixtures/files/DokuwikiMarkdownExport/pages/page1/subpage1.md b/playwright/support/fixtures/files/DokuwikiMarkdownExport/pages/page1/subpage1.md new file mode 100644 index 000000000..f8549e70b --- /dev/null +++ b/playwright/support/fixtures/files/DokuwikiMarkdownExport/pages/page1/subpage1.md @@ -0,0 +1,5 @@ +# Subpage1 + +Here is an image: + +![](:page1:triceratops.png?400) diff --git a/playwright/support/fixtures/files/DokuwikiMarkdownExport/pages/page2.md b/playwright/support/fixtures/files/DokuwikiMarkdownExport/pages/page2.md new file mode 100644 index 000000000..aae36acbf --- /dev/null +++ b/playwright/support/fixtures/files/DokuwikiMarkdownExport/pages/page2.md @@ -0,0 +1 @@ +# Page2 diff --git a/playwright/support/fixtures/files/DokuwikiMarkdownExport/pages/start.md b/playwright/support/fixtures/files/DokuwikiMarkdownExport/pages/start.md new file mode 100644 index 000000000..2b61b7c19 --- /dev/null +++ b/playwright/support/fixtures/files/DokuwikiMarkdownExport/pages/start.md @@ -0,0 +1,7 @@ +# Start page + +![](:stegosaurus.png?400) + +Link to [page1](page1) + +Link to [subpage1](page1:subpage1) diff --git a/playwright/support/fixtures/random-user.ts b/playwright/support/fixtures/random-user.ts index c570992e2..3d1e54f2d 100644 --- a/playwright/support/fixtures/random-user.ts +++ b/playwright/support/fixtures/random-user.ts @@ -7,7 +7,10 @@ import { createRandomUser, login } from '@nextcloud/e2e-test-server/playwright' import { test as base } from '@playwright/test' import { User } from './User.ts' +type RandomUser = Awaited> + export interface UserFixture { + randomUser: RandomUser user: User } @@ -15,21 +18,25 @@ export interface UserFixture { * This test fixture ensures a new random user is created and used for the test (current page) */ export const test = base.extend({ - page: async ({ browser, baseURL }, use) => { + // eslint-disable-next-line no-empty-pattern + randomUser: async ({}, use) => { + const randomUser = await createRandomUser() + await use(randomUser) + }, + page: async ({ browser, baseURL, randomUser }, use) => { // Important: make sure we authenticate in a clean environment by unsetting storage state. const page = await browser.newPage({ storageState: undefined, baseURL, }) - const randomUser = await createRandomUser() await login(page.request, randomUser) await use(page) await page.close() }, - user: async ({ page }, use) => { - const user = new User(page) + user: async ({ page, randomUser }, use) => { + const user = new User(page, randomUser.userId) await use(user) }, }) diff --git a/playwright/support/fixtures/urls.ts b/playwright/support/fixtures/urls.ts new file mode 100644 index 000000000..0afb5973d --- /dev/null +++ b/playwright/support/fixtures/urls.ts @@ -0,0 +1,17 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * Generate OCS API URL for collectives + * We cannot use apiURL() from the app as it relies on browser APIs (window) + * + * @param version - Version of the API - currently `v1.0` + * @param parts - URL parts to append - will be joined with `/` + */ +export function apiUrl(version: string, ...parts: (string | number)[]): string { + const path = ['apps/collectives/api', version, ...parts] + .join('/') + return `/ocs/v2.php/${path}` +} diff --git a/playwright/support/sections/EditorSection.ts b/playwright/support/sections/EditorSection.ts new file mode 100644 index 000000000..1e6d5d506 --- /dev/null +++ b/playwright/support/sections/EditorSection.ts @@ -0,0 +1,50 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { type Locator, type Page } from '@playwright/test' +import { expect } from '@playwright/test' + +export class EditorSection { + public mode: 'reader' | 'editor' + public readonly editorContent: Locator + public readonly readerContent: Locator + public readonly content: Locator + + constructor(public readonly page: Page) { + this.mode = 'reader' + this.editorContent = this.page.locator('[data-cy-collectives="editor"] .ProseMirror') + this.readerContent = this.page.locator('[data-cy-collectives="reader"] .ProseMirror') + this.content = this.mode === 'reader' ? this.readerContent : this.editorContent + } + + public setMode(mode: 'reader' | 'editor') { + this.mode = mode + } + + public async hasImage(filename: string): Promise { + const srcRegex = new RegExp(`imageFileName=${filename}`) + await expect(this.content + .locator('img')) + .toHaveAttribute('src', srcRegex) + } + + public async getLinkBubble(linkText: string): Promise { + await this.content + .getByRole('link', { name: linkText, exact: true }) + .click() + await this.page.locator('.widget-custom') + .waitFor({ state: 'visible' }) + return this.page.locator('.widget-custom') + } + + public async hasInternalLink(linkText: string): Promise { + await expect((await this.getLinkBubble(linkText)) + .locator('.collective-page .line')) + .toHaveText(linkText) + // Click somewhere else to close the link bubble + await this.content + .click() + } +} diff --git a/tests/Unit/Fs/MarkdownHelperTest.php b/tests/Unit/Fs/MarkdownHelperTest.php index d51953846..9287c51af 100644 --- a/tests/Unit/Fs/MarkdownHelperTest.php +++ b/tests/Unit/Fs/MarkdownHelperTest.php @@ -67,6 +67,29 @@ public function testGetLinksFromContent(string $content, array $linksProps): voi self::assertEquals($links, MarkdownHelper::getLinksFromContent($content)); } + public function imageContentProvider(): array { + return [ + // Valid image syntax + ['#Title\n\nImage: ![alt text](https://example.org/image.png)\n\nMore text...', [['alt text', 'https://example.org/image.png', '']]], + ['![alt text](https://example.org/image.png (title))', [['alt text', 'https://example.org/image.png', 'title']]], + ['![alt text](https://example.org/image.png "title")', [['alt text', 'https://example.org/image.png', 'title']]], + ['![](./image.png)', [['', './image.png', '']]], + ['![]()', [['', '/my%20image.png', '']]], + ]; + } + + /** + * @dataProvider imageContentProvider + */ + public function testGetImagesFromContent(string $content, array $imagesProps): void { + $images = []; + foreach ($imagesProps as $imageProps) { + $images[] = ['alt' => $imageProps[0], 'url' => $imageProps[1], 'title' => $imageProps[2]]; + } + self::assertEquals($images, MarkdownHelper::getImageLinksFromContent($content)); + } + + public function testGetLinkedPageIds(): void { $trustedDomains = ['nextcloud.local']; diff --git a/tests/stub.phpstub b/tests/stub.phpstub index cc758de83..9690c680b 100644 --- a/tests/stub.phpstub +++ b/tests/stub.phpstub @@ -56,52 +56,6 @@ namespace OC\Files\ObjectStore { class NoopScanner {} } -namespace Symfony\Component\Console\Helper { - use Symfony\Component\Console\Output\OutputInterface; - class Table { - public function __construct(OutputInterface $text) {} - public function setHeaders(array $header) {} - public function setRows(array $rows) {} - public function render() {} - } -} - -namespace Symfony\Component\Console\Input { - class InputInterface { - public function getOption(string $key) {} - public function setOption(string $key, $value) {} - public function getArgument(string $key) {} - } - class InputArgument { - const REQUIRED = 0; - const OPTIONAL = 1; - const IS_ARRAY = 1; - } - class InputOption { - const VALUE_NONE = 1; - const VALUE_REQUIRED = 1; - const VALUE_OPTIONAL = 1; - } -} - -namespace Symfony\Component\Console\Question { - class ConfirmationQuestion { - public function __construct(string $text, bool $default) {} - } -} - -namespace Symfony\Component\Console\Output { - class OutputInterface { - public const VERBOSITY_VERBOSE = 1; - public function writeln(string $text, int $flat = 0) {} - public function write($messages, $newline = false, $options = 0) {} - } -} - -namespace Symfony\Component\EventDispatcher { - class EventDispatcherInterface {} -} - namespace OC\Collaboration\Reference { use OCP\Collaboration\Reference\IReferenceManager;