diff --git a/lib/Listeners/NodeCopiedListener.php b/lib/Listeners/NodeCopiedListener.php index a66505caeb7..ce9d1f01344 100644 --- a/lib/Listeners/NodeCopiedListener.php +++ b/lib/Listeners/NodeCopiedListener.php @@ -36,9 +36,10 @@ public function handle(Event $event): void { && $target instanceof File && $target->getMimeType() === 'text/markdown' ) { - $this->attachmentService->copyAttachments($source, $target); + $fileIdMapping = $this->attachmentService->copyAttachments($source, $target); $target->unlock(ILockingProvider::LOCK_SHARED); AttachmentService::replaceAttachmentFolderId($source, $target); + AttachmentService::replaceAttachmentFileIds($target, $fileIdMapping); $target->lock(ILockingProvider::LOCK_SHARED); } } diff --git a/lib/Service/AttachmentService.php b/lib/Service/AttachmentService.php old mode 100644 new mode 100755 index 7f534b96efd..f1dcea56e69 --- a/lib/Service/AttachmentService.php +++ b/lib/Service/AttachmentService.php @@ -641,27 +641,34 @@ public function deleteAttachments(File $source): void { * @param File $source * @param File $target * + * @return array file id translation map * @throws InvalidPathException * @throws NoUserException * @throws NotFoundException * @throws NotPermittedException * @throws LockedException */ - public function copyAttachments(File $source, File $target): void { + public function copyAttachments(File $source, File $target): array { try { $sourceAttachmentDir = $this->getAttachmentDirectoryForFile($source); } catch (NotFoundException $e) { // silently return if no attachment dir was found for source file - return; + return []; } // create a new attachment dir next to the new file $targetAttachmentDir = $this->getAttachmentDirectoryForFile($target, true); // copy the attachment files + $fileIdMapping = []; foreach ($sourceAttachmentDir->getDirectoryListing() as $sourceAttachment) { if ($sourceAttachment instanceof File) { - $targetAttachmentDir->newFile($sourceAttachment->getName(), $sourceAttachment->getContent()); + $newFile = $targetAttachmentDir->newFile($sourceAttachment->getName(), $sourceAttachment->getContent()); + $fileIdMapping[] = [ + $sourceAttachment->getId(), + $newFile->getId() + ]; } } + return $fileIdMapping; } public static function replaceAttachmentFolderId(File $source, File $target): void { @@ -683,4 +690,18 @@ public static function replaceAttachmentFolderId(File $source, File $target): vo $target->putContent($content); } } + + public static function replaceAttachmentFileIds(File $target, array $fileIdMapping): void { + $patterns = []; + $replacements = []; + foreach ($fileIdMapping as $mapping) { + $patterns[] = '/(\[(?:\\\]|[^]])+\]\(\s*\S+\/f\/)' . $mapping[0] . '(\s*)(\(preview\)\s*)?\)/'; + // Replace `[title](URL/f/sourceId (preview))` with `[title](URL/f/targetId (preview))` + $replacements[] = '${1}' . $mapping[1] . '${2}${3})'; + } + $content = preg_replace($patterns, $replacements, $target->getContent()); + if ($content !== null) { + $target->putContent($content); + } + } } diff --git a/tests/unit/Service/AttachmentServiceTest.php b/tests/unit/Service/AttachmentServiceTest.php old mode 100644 new mode 100755 index 7283072a8d6..2bef429e340 --- a/tests/unit/Service/AttachmentServiceTest.php +++ b/tests/unit/Service/AttachmentServiceTest.php @@ -160,4 +160,84 @@ public function testReplaceAttachmentFolderIdNoReplace(string $sourceContent): v AttachmentService::replaceAttachmentFolderId($source, $target); $this->assertEquals($sourceContent, $replacedContent); } + + // Replacement expected + public static function contentReplaceAttachmentFileIdProvider(): array { + return [ + ['[image.png](https://localhost:8000/f/1)', '[image.png](https://localhost:8000/f/2)'], + ['[image.png](https://localhost:8000/f/1(preview))', '[image.png](https://localhost:8000/f/2(preview))'], + ['[image.png](https://localhost:8000/f/1 (preview))', '[image.png](https://localhost:8000/f/2 (preview))'], + // Link in title + ['[https://localhost:8000/f/1](https://localhost:8000/f/1)', '[https://localhost:8000/f/1](https://localhost:8000/f/2)'], + ['[https://localhost:8000/f/1](https://localhost:8000/f/1 (preview))', '[https://localhost:8000/f/1](https://localhost:8000/f/2 (preview))'], + // Spaces surrounding link URL + ['[image.png]( https://localhost:8000/f/1 )', '[image.png]( https://localhost:8000/f/2 )'], + ['[image.png]( https://localhost:8000/f/1 (preview))', '[image.png]( https://localhost:8000/f/2 (preview))'], + ['[image.png]( https://localhost:8000/f/1 (preview) )', '[image.png]( https://localhost:8000/f/2 (preview) )'], + // Escaped square brackets in title + ['[title \[#1\]](https://localhost:8000/f/1)', '[title \[#1\]](https://localhost:8000/f/2)'], + ['[title \[#1\]](https://localhost:8000/f/1 (preview))', '[title \[#1\]](https://localhost:8000/f/2 (preview))'], + // Spaces in title + ['[title with space](https://localhost:8000/f/1)', '[title with space](https://localhost:8000/f/2)'], + ['[title with space](https://localhost:8000/f/1 (preview))', '[title with space](https://localhost:8000/f/2 (preview))'], + // Several links in a row + ['Some text\n\n[image.png](https://localhost:8000/f/1 (preview))\n\nMore text. [file.tar.gz](https://localhost:8000/f/3) ...', 'Some text\n\n[image.png](https://localhost:8000/f/2 (preview))\n\nMore text. [file.tar.gz](https://localhost:8000/f/4) ...'], + ]; + } + + /** + * @dataProvider contentReplaceAttachmentFileIdProvider + */ + public function testReplaceAttachmentFileId(string $sourceContent, string $targetContent): void { + $target = $this->createMock(File::class); + $target->method('getId')->willReturn(2); + $target->method('getContent')->willReturn($sourceContent); + $replacedContent = ''; + $target->method('putContent')->willReturnCallback(function (string $content) use (&$replacedContent) { + $replacedContent = $content; + }); + + $fileIdMapping = [ + [1, 2], + [3, 4], + ]; + AttachmentService::replaceAttachmentFileIds($target, $fileIdMapping); + $this->assertEquals($targetContent, $replacedContent); + } + + // No replacement expected + public static function contentReplaceAttachmentFileIdNoReplaceProvider(): array { + return [ + // Empty title + [ '[](https://localhost:8000/f/1)' ], + // Different url + [ '[title](https://localhost:8000/f/1/a/asdf.png)' ], + // Wrong fileId #1 + [ '[image.png](https://localhost:8000/f/112)' ], + // Wrong fileId #2 + [ '[image.png](https://localhost:8000/f/12)' ], + // Normal brackets around title + [ '(image.png)(https://localhost:8000/f/1)' ], + // Square brackets in title + ['[title [#1]](https://localhost:8000/f/1)' ], + // Space between brackets + ['[title] (https://localhost:8000/f/1)' ], + ]; + } + + /** + * @dataProvider contentReplaceAttachmentFileIdNoReplaceProvider + */ + public function testReplaceAttachmentFileIdNoReplace(string $sourceContent): void { + $target = $this->createMock(File::class); + $target->method('getId')->willReturn(2); + $target->method('getContent')->willReturn($sourceContent); + $replacedContent = ''; + $target->method('putContent')->willReturnCallback(function (string $content) use (&$replacedContent) { + $replacedContent = $content; + }); + + AttachmentService::replaceAttachmentFileIds($target, [[1, 2]]); + $this->assertEquals($sourceContent, $replacedContent); + } }