PluginProbe
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration / 1.95.3
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration v1.95.3
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 1.95.3, at app/Services/CommentService.php

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