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:
+
+
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
+
+
+
+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: \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']]],
+ ['', [['', './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;