PluginProbe
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration / 2.0.1
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration v2.0.1
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 / CommentService.php

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

483 lines 16.3 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\Comment;
6 use FluentBoards\App\Models\CommentImage;
7 use FluentBoards\App\Models\Task;
8 use FluentBoards\App\Models\TaskActivity;
9 use FluentBoardsPro\App\Services\AttachmentService;
10 use FluentBoardsPro\App\Services\RemoteUrlParser;
11
12 class CommentService
13 {
14 /**
15 * Resolve a comment only when its task belongs to the requested board.
16 *
17 * @param int $commentId
18 * @param int $boardId
19 * @return Comment
20 * @throws \Exception
21 */
22 public function findCommentOnBoard($commentId, $boardId)
23 {
24 $comment = Comment::findOrFail($commentId);
25 (new TaskService())->findTaskOnBoard($comment->task_id, $boardId);
26
27 if ($comment->board_id && (int) $comment->board_id !== absint($boardId)) {
28 throw new \Exception(esc_html__('Comment not found', 'fluent-boards'));
29 }
30
31 return $comment;
32 }
33
34 /**
35 * Get paginated parent comments with users, images, and replies preloaded.
36 */
37 public function getComments($id, $per_page, $filter, $boardId = null)
38 {
39 $task = $boardId ? (new TaskService())->findTaskOnBoard($id, $boardId) : Task::findOrFail($id);
40
41 $commentsQuery = $task->comments()->whereNull('parent_id')
42 ->with(['user', 'images', 'replies.user', 'replies.images']);
43
44 if ($filter == 'oldest') {
45 $commentsQuery = $commentsQuery->oldest();
46 } else { // latest or newest
47 $commentsQuery = $commentsQuery->latest();
48 }
49 $comments = $commentsQuery->paginate($per_page);
50
51 foreach ($comments as $comment) {
52 $comment->replies_count = count($comment->replies);
53 }
54
55 return $comments;
56 }
57
58 public function getTotal($id, $boardId = null)
59 {
60 $task = $boardId ? (new TaskService())->findTaskOnBoard($id, $boardId) : Task::findOrFail($id);
61 $totalComment = Comment::where('task_id', $task->id)
62 ->type('comment')
63 ->count();
64 $totalReply = Comment::where('task_id', $task->id)
65 ->type('reply')
66 ->count();
67
68 return $totalComment + $totalReply;
69 }
70
71 public function getReplies($comment)
72 {
73 $replies = Comment::where('parent_id', $comment->id)->with(['user'])->get();
74 return $replies;
75 }
76
77 public function create($commentData, $id, $boardId = null)
78 {
79 if ($boardId) {
80 (new TaskService())->findTaskOnBoard($id, $boardId);
81
82 if (!empty($commentData['parent_id'])) {
83 $parentComment = $this->findCommentOnBoard($commentData['parent_id'], $boardId);
84
85 if ((int) $parentComment->task_id !== (int) $id) {
86 throw new \Exception(esc_html__('Comment not found', 'fluent-boards'));
87 }
88 }
89 }
90
91 $comment = Comment::create($commentData);
92 do_action('fluent_boards/comment_created', $comment);
93 return $comment;
94 }
95
96 private function startsWithAt($word) {
97 return mb_strpos($word, '@') === 0;
98 }
99
100 private function isValidUrl($url)
101 {
102 try {
103 if (empty($url)) {
104 return false;
105 }
106
107 $url = trim($url);
108
109 // Early return for obviously invalid formats
110 if ($url === 'http://' || $url === 'https://') {
111 return false;
112 }
113
114 // Handle www. URLs
115 if (strpos($url, 'www.') === 0) {
116 $url = 'http://' . $url;
117 }
118 // If it's not already a URL, make it one
119 elseif (!preg_match('~^(?:f|ht)tps?://~i', $url)) {
120 $url = 'https://' . $url;
121 }
122
123 $components = wp_parse_url($url);
124
125 if (empty($components) || !isset($components['host'])) {
126 return false;
127 }
128
129 // Additional validation with filter_var
130 $isValid = filter_var($url, FILTER_VALIDATE_URL) !== false;
131 return $isValid;
132
133 } catch (\Exception $e) {
134 return false;
135 }
136 }
137
138 private function extractUrls($text) {
139 try {
140 // More permissive URL pattern that handles international domains and various formats
141 $urlPattern = '%\b(?:(?:https?|ftp):\/\/|www\.)[^\s<>\[\]{}"\']+'
142 . '(?:\([^\s<>\[\]{}"\')]*\)|[^\s<>\[\]{}"\'\)])*%iu';
143
144 if (preg_match_all($urlPattern, $text, $matches)) {
145 $urls = array_filter($matches[0], function($url) {
146 $trimmed = trim($url);
147 return !empty($trimmed);
148 });
149 return array_values($urls); // Re-index array
150 }
151 return [];
152 } catch (\Exception $e) {
153 return [];
154 }
155 }
156
157 private function extractMentions($text) {
158 try {
159 // Pattern that combines zero-width delimiters with international username support
160 $pattern = '/@\x{200B}([\p{L}\p{N}_. -@]+)\x{200C}/u';
161
162 if (preg_match_all($pattern, $text, $matches)) {
163 $mentions = array_filter($matches[1], function($mention) {
164 $trimmed = trim($mention);
165 return !empty($trimmed);
166 });
167 return array_values($mentions); // Re-index array
168 }
169 return [];
170 } catch (\Exception $e) {
171 return [];
172 }
173 }
174
175 public function processMentionAndLink($commentDescription, $mentionData = [])
176 {
177 if (empty($commentDescription)) {
178 return '';
179 }
180
181 try {
182 // Ensure UTF-8 encoding with error handling
183 $commentDescription = mb_convert_encoding($commentDescription, 'UTF-8', 'auto');
184
185 // Extract all URLs from the text
186 $urls = $this->extractUrls($commentDescription);
187 $urlReplacements = [];
188
189 if (!empty($urls)) {
190 foreach ($urls as $url) {
191 if ($this->isValidUrl($url)) {
192 $urlReplacements[$url] = sprintf(
193 '<a class="fbs_link" target="_blank" rel="noopener noreferrer" href="%1$s">%1$s</a>',
194 esc_url($url)
195 );
196 }
197 }
198 }
199
200 // Process mentions
201 $mentionedUsernames = [];
202 if (!empty($mentionData) && is_array($mentionData)) {
203 foreach ($mentionData as $mentionedId) {
204 $user = get_userdata($mentionedId);
205 if ($user) {
206 $mentionedUsernames[$user->user_login] = [
207 'user_id' => $user->ID,
208 'display_name' => htmlspecialchars($user->display_name, ENT_QUOTES, 'UTF-8')
209 ];
210 }
211 }
212 }
213
214 // Extract all mentions from the text
215 $mentions = $this->extractMentions($commentDescription);
216 $mentionReplacements = [];
217
218 if (!empty($mentions)) {
219 foreach ($mentions as $mention) {
220 if (array_key_exists($mention, $mentionedUsernames)) {
221 $mentionReplacements['@' . "\u{200B}" . $mention . "\u{200C}"] = sprintf(
222 '<a class="fbs_mention" href="%smember/%d/tasks">%s</a>',
223 esc_url(fluent_boards_page_url()),
224 $mentionedUsernames[$mention]['user_id'],
225 $mentionedUsernames[$mention]['display_name']
226 );
227 }
228 }
229 }
230
231 // Apply replacements
232 $originalText = $commentDescription;
233
234 // First replace URLs (longer strings first to avoid partial replacements)
235 if (!empty($urls)) {
236 usort($urls, function($a, $b) {
237 return strlen($b) - strlen($a);
238 });
239 foreach ($urls as $url) {
240 if (isset($urlReplacements[$url])) {
241 $commentDescription = str_replace($url, $urlReplacements[$url], $commentDescription);
242 }
243 }
244 }
245
246 // Then replace mentions
247 if (!empty($mentionReplacements)) {
248 foreach ($mentionReplacements as $mention => $replacement) {
249 $commentDescription = str_replace($mention, $replacement, $commentDescription);
250 }
251 }
252
253 return $commentDescription;
254
255 } catch (\Exception $e) {
256 return $commentDescription; // Return original text if processing fails
257 }
258 }
259
260 public function checkIfCommentHaveLinks($comment)
261 {
262 if (empty($comment)) {
263 return '';
264 }
265
266 try {
267 // Ensure UTF-8 encoding
268 $comment = mb_convert_encoding($comment, 'UTF-8', 'auto');
269
270 // Extract all URLs from the text
271 $urls = $this->extractUrls($comment);
272 $hasLinks = false;
273
274 // Replace URLs with links
275 if (!empty($urls)) {
276 foreach ($urls as $url) {
277 if ($this->isValidUrl($url)) {
278 $hasLinks = true;
279 $replacement = sprintf(
280 '<a class="fbs_link" target="_blank" rel="noopener noreferrer" href="%1$s">%1$s</a>',
281 esc_url($url)
282 );
283 $comment = str_replace($url, $replacement, $comment);
284 }
285 }
286 }
287
288 return $hasLinks ? $comment : $comment;
289
290 } catch (\Exception $e) {
291 return $comment; // Return original text if processing fails
292 }
293 }
294
295 public function attachCommentImages($comment, $imageIds)
296 {
297
298 foreach ($imageIds as $imageId)
299 {
300 $attachmentObject = CommentImage::findOrFail($imageId);
301 if($attachmentObject) {
302 if ($attachmentObject->object_id == $comment->id && $attachmentObject->object_type == Constant::COMMENT_IMAGE) {
303 continue;
304 }
305 $attachmentObject->object_id = $comment->id;
306 $attachmentObject->object_type = Constant::COMMENT_IMAGE;
307 $attachmentObject->save();
308 }
309 }
310 //if(in_array("banana", $imageIds))
311 $commentImages = CommentImage::where('object_id', $comment->id)->where('object_type', Constant::COMMENT_IMAGE)->get();
312
313 foreach ($commentImages as $commentImage) {
314 if(!in_array($commentImage->id, $imageIds)) {
315 $deletedImage = clone $commentImage;
316 $commentImage->delete();
317 //do_action('fluent_boards/comment_image_deleted', $deletedImage);
318 }
319 }
320 }
321
322 public function update($commentData, $comment_id, $mentionData, $boardId = null)
323 {
324 $comment = $boardId ? $this->findCommentOnBoard($comment_id, $boardId) : Comment::findOrFail($comment_id);
325
326 if ($comment->created_by != get_current_user_id()) {
327 return false;
328 }
329
330 $allMentionedIds = array_unique(array_merge($comment->settings['mentioned_id'] ?? [], is_array($mentionData) ? $mentionData : []));
331
332 if ($allMentionedIds) {
333 $processedDescription = $this->processMentionAndLink($commentData['description'], $allMentionedIds);
334 } elseif(!$allMentionedIds) {
335 $processedDescription = $this->checkIfCommentHaveLinks($commentData['description']);
336 }
337
338 $oldComment = $comment->settings['raw_description'] ?? $comment->description;
339 $comment->description = $processedDescription;
340
341 if($comment->settings != null)
342 {
343 $tempSettings = $comment->settings;
344 $tempSettings['raw_description'] = $commentData['description'];
345 $tempSettings['mentioned_id'] = $allMentionedIds;
346 $comment->settings = $tempSettings;
347 } else {
348 $comment->settings = [
349 'raw_description' => $commentData['description'],
350 'mentioned_id' => $allMentionedIds
351 ];
352 }
353 $comment->save();
354
355 if(!$comment->parent_id) {
356 do_action('fluent_boards/comment_updated', $comment, $oldComment);
357 }
358
359 return $comment;
360 }
361
362 public function delete($comment_id, $boardId = null)
363 {
364 $comment = $boardId ? $this->findCommentOnBoard($comment_id, $boardId) : Comment::findOrFail($comment_id);
365
366 if ($comment->created_by != get_current_user_id()) {
367 return false;
368 }
369
370 // Delete related replies first (model event will handle their images)
371 $this->relatedReplyDelete($comment_id);
372
373 // Delete the comment (model deleting event will handle images and comments_count)
374 $comment->delete();
375
376 do_action('fluent_boards/comment_deleted', $comment);
377 }
378
379 public function relatedReplyDelete($comment_id)
380 {
381 $replies = Comment::where('parent_id', $comment_id)
382 ->type('reply')
383 ->get();
384 foreach ($replies as $reply) {
385 // Delete reply (model deleting event will handle images)
386 $reply->delete();
387 }
388 }
389
390 public function updateReply($replyData, $id, $boardId = null)
391 {
392 $reply = $boardId ? $this->findCommentOnBoard($id, $boardId) : Comment::findOrFail($id);
393
394 if ($reply->created_by != get_current_user_id()) {
395 return false;
396 }
397
398 $oldReply = $reply->description;
399 $reply->description = $replyData['description'];
400 $reply->save();
401 // do_action('fluent_boards/task_comment_updated', $comment->task_id, $oldComment, $comment->description);
402
403 return $reply;
404 }
405
406 public function deleteReply($id, $boardId = null)
407 {
408 $reply = $boardId ? $this->findCommentOnBoard($id, $boardId) : Comment::findOrFail($id);
409 // $taskId = $reply->task_id;
410
411 if ($reply->created_by != get_current_user_id()) {
412 return false;
413 }
414
415 // Delete reply (model deleting event will handle images)
416 $reply->delete();
417
418 // do_action('fluent_boards/comment_deleted', $taskId);
419 }
420
421 /**
422 * Adds a task attachment to the specified task.
423 *
424 * @param int $taskId The ID of the task to which the attachment is added.
425 * @param string $title The title of the attachment.
426 * @param string $url The URL of the attachment.
427 *
428 * @return Attachment The updated list of task attachments.
429 * @throws \Exception
430 */
431 public function createCommentImage($data, $boardId)
432 {
433 /*
434 * I will refactor this function later- within March 2024 Last Week
435 */
436 $initialDataData = [
437 'type' => 'url',
438 'url' => '',
439 'name' => '',
440 'size' => 0,
441 ];
442
443 $attachData = array_merge($initialDataData, $data);
444 $UrlMeta = [];
445 if($attachData['type'] == 'url') {
446 $UrlMeta = RemoteUrlParser::parse($attachData['url']);
447 }
448 $attachment = new CommentImage();
449 $attachment->object_id = 0;
450 $attachment->object_type = Constant::COMMENT_IMAGE;
451 $attachment->attachment_type = $attachData['type'];
452 $attachment->title = $this->setTitle($attachData['type'], $attachData['name'], $UrlMeta);
453 $attachment->file_path = $attachData['type'] != 'url' ? $attachData['file'] : null;
454 $attachment->full_url = esc_url($attachData['url']);
455 $attachment->file_size = $attachData['size'];
456 $attachment->settings = $attachData['type'] == 'url' ? [
457 'meta' => $UrlMeta
458 ] : '';
459 $attachment->driver = 'local';
460 $attachment->save();
461
462 return $attachment;
463 }
464
465 public function createPublicUrl($attachment, $boardId)
466 {
467 return add_query_arg([
468 'fbs' => 1,
469 'fbs_type' => 'public_url',
470 'fbs_bid' => $boardId,
471 'fbs_comment_image' => $attachment->file_hash
472 ], site_url('/index.php'));
473 }
474
475 private function setTitle($type, $title, $UrlMeta)
476 {
477 if($type != 'url') {
478 return sanitize_file_name($title);
479 }
480 return $title ?? $UrlMeta['title'] ?? '';
481 }
482 }
483