PluginProbe
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration / trunk
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration vtrunk
2.0.15 2.0.12 2.0.10 2.0.4 2.0.1 2.0.0 1.95.3 1.95.2 1.95 1.91.6 trunk 1.11 1.12 1.13 1.20 1.21 1.22 1.23 1.30 1.31 1.32 1.35 1.40 1.41 1.45 All 41 releases
fluent-boards / app / Services / AttachmentFileService.php

AttachmentFileService.php in FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration trunk, at app/Services/AttachmentFileService.php

481 lines 16.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentBoards\App\Services;
4
5 use FluentBoards\App\Models\Attachment;
6 use FluentBoards\App\Models\Comment;
7 use FluentBoards\App\Models\CommentImage;
8 use FluentBoards\App\Models\Task;
9 use FluentBoards\App\Models\TaskImage;
10 use FluentBoards\App\Services\Libs\FileSystem;
11
12 class AttachmentFileService
13 {
14 protected $createdFiles = [];
15 protected $createdAttachments = [];
16 protected $movedOriginalFiles = [];
17
18 public function moveTaskFilesToBoard(Task $task, $sourceBoardId, $targetBoardId)
19 {
20 if ((int) $sourceBoardId === (int) $targetBoardId) {
21 return $task;
22 }
23
24 $descriptionUrlMap = [];
25 $movedImages = [];
26
27 $images = TaskImage::where('object_id', $task->id)
28 ->where('object_type', Constant::TASK_DESCRIPTION)
29 ->get();
30 $taskAttachments = $this->getTaskAttachmentsForMove($task);
31 $sharedFullUrls = $this->getSharedFullUrlLookup($this->combineAttachmentCollections($images, $taskAttachments));
32
33 foreach ($images as $image) {
34 $result = $this->moveAttachmentToBoard($image, $sourceBoardId, $targetBoardId, $sharedFullUrls);
35 $movedImages[$image->id] = $result;
36
37 if (!empty($result['old_public_url']) && !empty($result['new_public_url'])) {
38 $descriptionUrlMap[$result['old_public_url']] = $result['new_public_url'];
39 }
40 }
41
42 $this->moveTaskAttachmentsToBoard($taskAttachments, $sourceBoardId, $targetBoardId, $sharedFullUrls);
43 $this->rewriteTaskDescriptionUrls($task, $descriptionUrlMap);
44 $this->updateTaskCoverFromFileResults($task, $movedImages, $targetBoardId);
45
46 return $task;
47 }
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
93 public function cloneTaskFilesToBoard(Task $sourceTask, Task $targetTask, $targetBoardId, array $options = [])
94 {
95 $options = array_merge([
96 'description_images' => true,
97 'cover' => true,
98 'task_attachments' => true,
99 ], $options);
100
101 $descriptionUrlMap = [];
102 $clonedImages = [];
103 $coverImageId = $this->getCoverImageId($sourceTask);
104
105 if ($options['description_images'] || ($options['cover'] && $coverImageId)) {
106 $imageQuery = TaskImage::where('object_id', $sourceTask->id)
107 ->where('object_type', Constant::TASK_DESCRIPTION);
108
109 if (!$options['description_images'] && $coverImageId) {
110 $imageQuery->where('id', $coverImageId);
111 }
112
113 $images = $imageQuery->get();
114
115 foreach ($images as $image) {
116 $result = $this->cloneAttachmentToBoard($image, $targetTask->id, $targetBoardId, Constant::TASK_DESCRIPTION, $sourceTask->board_id);
117 $clonedImages[$image->id] = $result;
118
119 if (!empty($result['old_public_url']) && !empty($result['new_public_url'])) {
120 $descriptionUrlMap[$result['old_public_url']] = $result['new_public_url'];
121 }
122 }
123 }
124
125 if ($options['task_attachments']) {
126 $this->cloneTaskAttachmentsToBoard($sourceTask, $targetTask, $targetBoardId);
127 }
128
129 $this->rewriteTaskDescriptionUrls($targetTask, $descriptionUrlMap);
130 $this->updateTaskCoverFromFileResults($targetTask, $clonedImages, $targetBoardId, $coverImageId);
131 $this->refreshTaskAttachmentCount($targetTask);
132 $targetTask->save();
133
134 return $targetTask;
135 }
136
137 public function cloneAttachmentToBoard(Attachment $source, $targetObjectId, $targetBoardId, $objectType = null, $sourceBoardId = null)
138 {
139 $clonedAttachment = $source->replicate();
140 $clonedAttachment->object_id = (int) $targetObjectId;
141 $clonedAttachment->object_type = $objectType ?: $source->object_type;
142 $clonedAttachment->file_hash = $this->generateFileHash();
143
144 $result = $this->copyAttachmentFileData($source, $clonedAttachment, $sourceBoardId, $targetBoardId);
145 if ($this->isLocalFileAttachment($source) && empty($result['copied'])) {
146 return $result;
147 }
148
149 $clonedAttachment->save();
150 $this->createdAttachments[] = [
151 'class' => get_class($clonedAttachment),
152 'id' => $clonedAttachment->id,
153 ];
154
155 $result['attachment'] = $clonedAttachment;
156 $result['new_attachment_id'] = $clonedAttachment->id;
157 $result['new_public_url'] = $this->createPublicUrl($clonedAttachment, $targetBoardId);
158
159 return $result;
160 }
161
162 public function moveAttachmentToBoard(Attachment $attachment, $sourceBoardId, $targetBoardId, array $sharedFullUrls = [])
163 {
164 $oldFullUrl = $attachment->full_url;
165 $oldPublicUrl = $this->createPublicUrl($attachment, $sourceBoardId);
166
167 $result = $this->copyAttachmentFileData($attachment, $attachment, $sourceBoardId, $targetBoardId);
168 if ($this->isLocalFileAttachment($attachment) && empty($result['copied'])) {
169 $result['attachment'] = $attachment;
170 $result['old_public_url'] = $oldPublicUrl;
171 $result['new_public_url'] = $oldPublicUrl;
172
173 return $result;
174 }
175
176 $attachment->file_hash = $this->generateFileHash();
177 $attachment->save();
178
179 $result['attachment'] = $attachment;
180 $result['old_public_url'] = $oldPublicUrl;
181 $result['new_public_url'] = $this->createPublicUrl($attachment, $targetBoardId);
182
183 if ($this->isLocalFileAttachment($attachment) && !empty($result['old_path']) && file_exists($result['old_path'])) {
184 if (empty($sharedFullUrls[$oldFullUrl]) && $result['old_path'] !== $result['new_path']) {
185 $this->movedOriginalFiles[] = $result['old_path'];
186 }
187 }
188
189 return $result;
190 }
191
192 public function commitMovedOriginalFiles()
193 {
194 foreach (array_unique($this->movedOriginalFiles) as $file) {
195 if (file_exists($file)) {
196 wp_delete_file($file);
197 }
198 }
199
200 $this->createdFiles = [];
201 $this->createdAttachments = [];
202 $this->movedOriginalFiles = [];
203 }
204
205 public function rollbackCreatedFiles()
206 {
207 foreach (array_reverse($this->createdAttachments) as $createdAttachment) {
208 if (!class_exists($createdAttachment['class'])) {
209 continue;
210 }
211
212 $attachment = $createdAttachment['class']::find($createdAttachment['id']);
213 if ($attachment) {
214 $attachment->delete();
215 }
216 }
217
218 foreach (array_reverse($this->createdFiles) as $file) {
219 if (file_exists($file)) {
220 wp_delete_file($file);
221 }
222 }
223
224 $this->createdFiles = [];
225 $this->createdAttachments = [];
226 $this->movedOriginalFiles = [];
227 }
228
229 protected function cloneTaskAttachmentsToBoard(Task $sourceTask, Task $targetTask, $targetBoardId)
230 {
231 if (!defined('FLUENT_BOARDS_PRO_VERSION')) {
232 return;
233 }
234
235 $attachments = \FluentBoardsPro\App\Models\TaskAttachment::where('object_id', $sourceTask->id)
236 ->where('object_type', Constant::TASK_ATTACHMENT)
237 ->get();
238
239 foreach ($attachments as $attachment) {
240 $this->cloneAttachmentToBoard($attachment, $targetTask->id, $targetBoardId, Constant::TASK_ATTACHMENT, $sourceTask->board_id);
241 }
242 }
243
244 protected function moveTaskAttachmentsToBoard($attachments, $sourceBoardId, $targetBoardId, array $sharedFullUrls)
245 {
246 foreach ($attachments as $attachment) {
247 $this->moveAttachmentToBoard($attachment, $sourceBoardId, $targetBoardId, $sharedFullUrls);
248 }
249 }
250
251 protected function getTaskAttachmentsForMove(Task $task)
252 {
253 if (!defined('FLUENT_BOARDS_PRO_VERSION')) {
254 return [];
255 }
256
257 return \FluentBoardsPro\App\Models\TaskAttachment::where('object_id', $task->id)
258 ->where('object_type', Constant::TASK_ATTACHMENT)
259 ->get();
260 }
261
262 protected function combineAttachmentCollections(...$collections)
263 {
264 $attachments = [];
265
266 foreach ($collections as $collection) {
267 foreach ($collection as $attachment) {
268 $attachments[] = $attachment;
269 }
270 }
271
272 return $attachments;
273 }
274
275 protected function getSharedFullUrlLookup(array $attachments)
276 {
277 $fullUrls = [];
278 $attachmentIds = [];
279
280 foreach ($attachments as $attachment) {
281 if (!empty($attachment->full_url)) {
282 $fullUrls[] = $attachment->full_url;
283 $attachmentIds[] = (int) $attachment->id;
284 }
285 }
286
287 $fullUrls = array_values(array_unique($fullUrls));
288 $attachmentIds = array_filter(array_unique($attachmentIds));
289
290 if (!$fullUrls) {
291 return [];
292 }
293
294 $sharedUrls = Attachment::whereIn('full_url', $fullUrls)
295 ->whereNotIn('id', $attachmentIds)
296 ->pluck('full_url')
297 ->toArray();
298
299 return array_fill_keys($sharedUrls, true);
300 }
301
302 protected function copyAttachmentFileData(Attachment $source, Attachment $target, $sourceBoardId, $targetBoardId)
303 {
304 $result = [
305 'old_path' => null,
306 'new_path' => null,
307 'old_full_url' => $source->full_url,
308 'new_full_url' => $source->full_url,
309 'old_public_url' => $sourceBoardId ? $this->createPublicUrl($source, $sourceBoardId) : null,
310 'new_public_url' => null,
311 'copied' => false,
312 ];
313
314 if (!$this->isLocalFileAttachment($source)) {
315 return $result;
316 }
317
318 $sourcePath = $this->resolveLocalPath($source, $sourceBoardId);
319 if (!$sourcePath || !is_file($sourcePath) || !is_readable($sourcePath)) {
320 return $result;
321 }
322
323 $targetDir = $this->getBoardDir($targetBoardId);
324 if (!is_dir($targetDir)) {
325 wp_mkdir_p($targetDir);
326 }
327
328 $filename = $this->uniqueFilename($targetDir, $this->getAttachmentFilename($source));
329 $targetPath = $targetDir . DIRECTORY_SEPARATOR . $filename;
330
331 if (!copy($sourcePath, $targetPath)) {
332 throw new \Exception(esc_html__('Could not copy attachment file.', 'fluent-boards'));
333 }
334
335 $this->createdFiles[] = $targetPath;
336
337 $target->file_path = $this->shouldStoreAbsolutePath($source) ? $targetPath : $filename;
338 $target->full_url = $this->getBoardUrl($targetBoardId) . '/' . rawurlencode($filename);
339 $target->driver = 'local';
340
341 $result['old_path'] = $sourcePath;
342 $result['new_path'] = $targetPath;
343 $result['new_full_url'] = $target->full_url;
344 $result['copied'] = true;
345
346 return $result;
347 }
348
349 protected function rewriteTaskDescriptionUrls(Task $task, array $urlMap)
350 {
351 if (!$urlMap || empty($task->description)) {
352 return;
353 }
354
355 $description = $task->description;
356
357 foreach ($urlMap as $oldUrl => $newUrl) {
358 $description = str_replace($oldUrl, $newUrl, $description);
359 $description = str_replace(esc_url($oldUrl), esc_url($newUrl), $description);
360 $description = str_replace(esc_attr($oldUrl), esc_attr($newUrl), $description);
361 }
362
363 if ($description !== $task->description) {
364 $task->description = $description;
365 }
366 }
367
368 protected function updateTaskCoverFromFileResults(Task $task, array $fileResults, $targetBoardId, $sourceCoverImageId = null)
369 {
370 $settings = $task->settings;
371 if (empty($settings['cover']) || !is_array($settings['cover'])) {
372 return;
373 }
374
375 $coverImageId = $sourceCoverImageId ?: $this->getCoverImageId($task);
376 if (!$coverImageId || empty($fileResults[$coverImageId]['attachment'])) {
377 return;
378 }
379
380 $newCoverAttachment = $fileResults[$coverImageId]['attachment'];
381 $settings['cover']['imageId'] = $newCoverAttachment->id;
382 $settings['cover']['backgroundImage'] = $this->createPublicUrl($newCoverAttachment, $targetBoardId);
383 $task->settings = $settings;
384 }
385
386 protected function refreshTaskAttachmentCount(Task $task)
387 {
388 if (!defined('FLUENT_BOARDS_PRO_VERSION')) {
389 return;
390 }
391
392 $settings = $task->settings ?: [];
393 $settings['attachment_count'] = \FluentBoardsPro\App\Models\TaskAttachment::where('object_id', $task->id)
394 ->where('object_type', Constant::TASK_ATTACHMENT)
395 ->count();
396 $task->settings = $settings;
397 }
398
399 protected function resolveLocalPath(Attachment $attachment, $boardId)
400 {
401 return FileSystem::resolveLocalAttachmentPath($attachment->file_path, $boardId);
402 }
403
404 protected function getAttachmentFilename(Attachment $attachment)
405 {
406 if (!empty($attachment->file_path)) {
407 return basename($attachment->file_path);
408 }
409
410 $urlPath = wp_parse_url($attachment->full_url, PHP_URL_PATH);
411 if ($urlPath) {
412 return basename($urlPath);
413 }
414
415 return sanitize_file_name($attachment->title ?: wp_generate_uuid4());
416 }
417
418 protected function uniqueFilename($dir, $filename)
419 {
420 $filename = sanitize_file_name($filename);
421
422 if (function_exists('wp_unique_filename')) {
423 return wp_unique_filename($dir, $filename);
424 }
425
426 $candidate = $filename;
427 $index = 1;
428 $info = pathinfo($filename);
429 $name = $info['filename'] ?? $filename;
430 $extension = !empty($info['extension']) ? '.' . $info['extension'] : '';
431
432 while (file_exists($dir . DIRECTORY_SEPARATOR . $candidate)) {
433 $candidate = $name . '-' . $index . $extension;
434 $index++;
435 }
436
437 return $candidate;
438 }
439
440 protected function isLocalFileAttachment(Attachment $attachment)
441 {
442 return $attachment->attachment_type !== 'url'
443 && ($attachment->driver === 'local' || empty($attachment->driver));
444 }
445
446 protected function shouldStoreAbsolutePath(Attachment $attachment)
447 {
448 return !empty($attachment->file_path) && is_file($attachment->file_path);
449 }
450
451 protected function getCoverImageId(Task $task)
452 {
453 $settings = $task->settings;
454 return !empty($settings['cover']['imageId']) ? (int) $settings['cover']['imageId'] : null;
455 }
456
457 protected function createPublicUrl(Attachment $attachment, $boardId)
458 {
459 return (new CommentService())->createPublicUrl($attachment, $boardId);
460 }
461
462 protected function getBoardDir($boardId)
463 {
464 return FileSystem::setSubDir('board_' . absint($boardId))->getDir();
465 }
466
467 protected function getBoardUrl($boardId)
468 {
469 $uploadDir = wp_upload_dir();
470 $fbsUploadDir = apply_filters('fluent_boards/upload_folder_name', FLUENT_BOARDS_UPLOAD_DIR);
471
472 return $uploadDir['baseurl'] . '/' . trim($fbsUploadDir, '/') . '/board_' . absint($boardId);
473 }
474
475 protected function generateFileHash()
476 {
477 $uid = wp_generate_uuid4();
478 return md5($uid . wp_rand(0, 1000));
479 }
480 }
481