| @@ -2,8 +2,10 @@ | ||
| 2 | 2 | |
| 3 | 3 | namespace FluentBoards\App\Services; |
| 4 | 4 | |
| 5 | 5 | use FluentBoards\App\Models\Attachment; |
| 6 | +use FluentBoards\App\Models\Comment; | |
| 7 | +use FluentBoards\App\Models\CommentImage; | |
| 6 | 8 | use FluentBoards\App\Models\Task; |
| 7 | 9 | use FluentBoards\App\Models\TaskImage; |
| 8 | 10 | use FluentBoards\App\Services\Libs\FileSystem; |
| 9 | 11 | |
| @@ -43,8 +45,52 @@ | ||
| 43 | 45 | |
| 44 | 46 | return $task; |
| 45 | 47 | } |
| 46 | 48 | |
| 49 | + /** | |
| 50 | + * Move every comment and reply image for a task to another board. | |
| 51 | + */ | |
| 52 | + public function moveCommentImagesToBoard($taskId, $sourceBoardId, $targetBoardId) | |
| 53 | + { | |
| 54 | + $taskId = absint($taskId); | |
| 55 | + $sourceBoardId = absint($sourceBoardId); | |
| 56 | + $targetBoardId = absint($targetBoardId); | |
| 57 | + | |
| 58 | + if (!$taskId || !$sourceBoardId || !$targetBoardId || $sourceBoardId === $targetBoardId) { | |
| 59 | + return; | |
| 60 | + } | |
| 61 | + | |
| 62 | + $commentIds = Comment::where('task_id', $taskId) | |
| 63 | + ->pluck('id') | |
| 64 | + ->toArray(); | |
| 65 | + $commentIds = array_filter(array_map('intval', $commentIds)); | |
| 66 | + | |
| 67 | + if (!$commentIds) { | |
| 68 | + return; | |
| 69 | + } | |
| 70 | + | |
| 71 | + $images = CommentImage::whereIn('object_id', $commentIds) | |
| 72 | + ->where('object_type', Constant::COMMENT_IMAGE) | |
| 73 | + ->get(); | |
| 74 | + $commentCreators = Comment::whereIn('id', $commentIds) | |
| 75 | + ->pluck('created_by', 'id') | |
| 76 | + ->toArray(); | |
| 77 | + $sharedFullUrls = $this->getSharedFullUrlLookup($this->combineAttachmentCollections($images)); | |
| 78 | + $commentService = new CommentService(); | |
| 79 | + | |
| 80 | + CommentImage::withoutTimestamps(function () use ($images, $sourceBoardId, $targetBoardId, $taskId, $sharedFullUrls, $commentCreators, $commentService) { | |
| 81 | + foreach ($images as $image) { | |
| 82 | + $commentService->applyCommentImageScope( | |
| 83 | + $image, | |
| 84 | + $targetBoardId, | |
| 85 | + $taskId, | |
| 86 | + $commentCreators[$image->object_id] ?? null | |
| 87 | + ); | |
| 88 | + $this->moveAttachmentToBoard($image, $sourceBoardId, $targetBoardId, $sharedFullUrls); | |
| 89 | + } | |
| 90 | + }); | |
| 91 | + } | |
| 92 | + | |
| 47 | 93 | public function cloneTaskFilesToBoard(Task $sourceTask, Task $targetTask, $targetBoardId, array $options = []) |
| 48 | 94 | { |
| 49 | 95 | $options = array_merge([ |
| 50 | 96 | 'description_images' => true, |
| @@ -181,9 +227,9 @@ | ||
| 181 | 227 | } |
| 182 | 228 | |
| 183 | 229 | protected function cloneTaskAttachmentsToBoard(Task $sourceTask, Task $targetTask, $targetBoardId) |
| 184 | 230 | { |
| 185 | - if (!class_exists('\FluentBoardsPro\App\Models\TaskAttachment')) { | |
| 231 | + if (!defined('FLUENT_BOARDS_PRO_VERSION')) { | |
| 186 | 232 | return; |
| 187 | 233 | } |
| 188 | 234 | |
| 189 | 235 | $attachments = \FluentBoardsPro\App\Models\TaskAttachment::where('object_id', $sourceTask->id) |
| @@ -203,9 +249,9 @@ | ||
| 203 | 249 | } |
| 204 | 250 | |
| 205 | 251 | protected function getTaskAttachmentsForMove(Task $task) |
| 206 | 252 | { |
| 207 | - if (!class_exists('\FluentBoardsPro\App\Models\TaskAttachment')) { | |
| 253 | + if (!defined('FLUENT_BOARDS_PRO_VERSION')) { | |
| 208 | 254 | return []; |
| 209 | 255 | } |
| 210 | 256 | |
| 211 | 257 | return \FluentBoardsPro\App\Models\TaskAttachment::where('object_id', $task->id) |
| @@ -269,9 +315,9 @@ | ||
| 269 | 315 | return $result; |
| 270 | 316 | } |
| 271 | 317 | |
| 272 | 318 | $sourcePath = $this->resolveLocalPath($source, $sourceBoardId); |
| 273 | - if (!$sourcePath || !file_exists($sourcePath)) { | |
| 319 | + if (!$sourcePath || !is_file($sourcePath) || !is_readable($sourcePath)) { | |
| 274 | 320 | return $result; |
| 275 | 321 | } |
| 276 | 322 | |
| 277 | 323 | $targetDir = $this->getBoardDir($targetBoardId); |
| @@ -305,21 +351,55 @@ | ||
| 305 | 351 | if (!$urlMap || empty($task->description)) { |
| 306 | 352 | return; |
| 307 | 353 | } |
| 308 | 354 | |
| 309 | - $description = $task->description; | |
| 310 | - | |
| 355 | + $replacements = []; | |
| 311 | 356 | foreach ($urlMap as $oldUrl => $newUrl) { |
| 312 | - $description = str_replace($oldUrl, $newUrl, $description); | |
| 313 | - $description = str_replace(esc_url($oldUrl), esc_url($newUrl), $description); | |
| 314 | - $description = str_replace(esc_attr($oldUrl), esc_attr($newUrl), $description); | |
| 357 | + $identity = $this->getPublicAttachmentUrlIdentity($oldUrl); | |
| 358 | + if ($identity !== null) { | |
| 359 | + $replacements[$identity] = $newUrl; | |
| 360 | + } | |
| 315 | 361 | } |
| 316 | 362 | |
| 363 | + // Keep HTML and Markdown delimiters outside the URL being replaced. | |
| 364 | + $description = preg_replace_callback('~https?://[^\s<>"\')\]]+~i', function ($matches) use ($replacements) { | |
| 365 | + $url = html_entity_decode($matches[0], ENT_QUOTES | ENT_HTML5, 'UTF-8'); | |
| 366 | + $identity = $this->getPublicAttachmentUrlIdentity($url); | |
| 367 | + if ($identity === null || !isset($replacements[$identity])) { | |
| 368 | + return $matches[0]; | |
| 369 | + } | |
| 370 | + | |
| 371 | + $newUrl = $replacements[$identity]; | |
| 372 | + return $url === $matches[0] ? $newUrl : esc_attr($newUrl); | |
| 373 | + }, $task->description); | |
| 374 | + | |
| 317 | 375 | if ($description !== $task->description) { |
| 318 | 376 | $task->description = $description; |
| 319 | 377 | } |
| 320 | 378 | } |
| 321 | 379 | |
| 380 | + /** | |
| 381 | + * Match stored public image URLs by endpoint and query, excluding renewable credentials. | |
| 382 | + */ | |
| 383 | + protected function getPublicAttachmentUrlIdentity($url) | |
| 384 | + { | |
| 385 | + $parts = wp_parse_url($url); | |
| 386 | + if (!$parts || empty($parts['query'])) { | |
| 387 | + return null; | |
| 388 | + } | |
| 389 | + | |
| 390 | + parse_str($parts['query'], $query); | |
| 391 | + if (($query['fbs_type'] ?? null) !== 'public_url' || empty($query['fbs_comment_image'])) { | |
| 392 | + return null; | |
| 393 | + } | |
| 394 | + | |
| 395 | + unset($query[Constant::ATTACHMENT_LEGACY_SIGNATURE_QUERY_KEY], $query[Constant::ATTACHMENT_LEGACY_EXPIRES_QUERY_KEY]); | |
| 396 | + ksort($query); | |
| 397 | + $parts['query'] = $query; | |
| 398 | + | |
| 399 | + return serialize($parts); | |
| 400 | + } | |
| 401 | + | |
| 322 | 402 | protected function updateTaskCoverFromFileResults(Task $task, array $fileResults, $targetBoardId, $sourceCoverImageId = null) |
| 323 | 403 | { |
| 324 | 404 | $settings = $task->settings; |
| 325 | 405 | if (empty($settings['cover']) || !is_array($settings['cover'])) { |
| @@ -338,9 +418,9 @@ | ||
| 338 | 418 | } |
| 339 | 419 | |
| 340 | 420 | protected function refreshTaskAttachmentCount(Task $task) |
| 341 | 421 | { |
| 342 | - if (!class_exists('\FluentBoardsPro\App\Models\TaskAttachment')) { | |
| 422 | + if (!defined('FLUENT_BOARDS_PRO_VERSION')) { | |
| 343 | 423 | return; |
| 344 | 424 | } |
| 345 | 425 | |
| 346 | 426 | $settings = $task->settings ?: []; |
| @@ -351,36 +431,9 @@ | ||
| 351 | 431 | } |
| 352 | 432 | |
| 353 | 433 | protected function resolveLocalPath(Attachment $attachment, $boardId) |
| 354 | 434 | { |
| 355 | - if (!empty($attachment->file_path) && file_exists($attachment->file_path)) { | |
| 356 | - return $attachment->file_path; | |
| 357 | - } | |
| 358 | - | |
| 359 | - if (!empty($attachment->full_url)) { | |
| 360 | - $uploadDir = wp_upload_dir(); | |
| 361 | - $pathFromUrl = rawurldecode(str_replace($uploadDir['baseurl'], $uploadDir['basedir'], $attachment->full_url)); | |
| 362 | - if (file_exists($pathFromUrl)) { | |
| 363 | - return $pathFromUrl; | |
| 364 | - } | |
| 365 | - | |
| 366 | - if ($boardId) { | |
| 367 | - $urlPath = wp_parse_url($attachment->full_url, PHP_URL_PATH); | |
| 368 | - $pathFromBoardUrl = $urlPath ? $this->getBoardDir($boardId) . DIRECTORY_SEPARATOR . basename(rawurldecode($urlPath)) : null; | |
| 369 | - if ($pathFromBoardUrl && file_exists($pathFromBoardUrl)) { | |
| 370 | - return $pathFromBoardUrl; | |
| 371 | - } | |
| 372 | - } | |
| 373 | - } | |
| 374 | - | |
| 375 | - if ($boardId && !empty($attachment->file_path)) { | |
| 376 | - $pathFromBoard = $this->getBoardDir($boardId) . DIRECTORY_SEPARATOR . basename(rawurldecode($attachment->file_path)); | |
| 377 | - if (file_exists($pathFromBoard)) { | |
| 378 | - return $pathFromBoard; | |
| 379 | - } | |
| 380 | - } | |
| 381 | - | |
| 382 | - return null; | |
| 435 | + return FileSystem::resolveLocalAttachmentPath($attachment->file_path, $boardId); | |
| 383 | 436 | } |
| 384 | 437 | |
| 385 | 438 | protected function getAttachmentFilename(Attachment $attachment) |
| 386 | 439 | { |
| @@ -425,9 +478,9 @@ | ||
| 425 | 478 | } |
| 426 | 479 | |
| 427 | 480 | protected function shouldStoreAbsolutePath(Attachment $attachment) |
| 428 | 481 | { |
| 429 | - return !empty($attachment->file_path) && file_exists($attachment->file_path); | |
| 482 | + return !empty($attachment->file_path) && is_file($attachment->file_path); | |
| 430 | 483 | } |
| 431 | 484 | |
| 432 | 485 | protected function getCoverImageId(Task $task) |
| 433 | 486 | { |