PluginProbe
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration / 2.2.1
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration v2.2.1
2.2.1 2.2.0 2.1.0 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 All 44 releases
fluent-boards / app / Modules / MCP / Helpers / MCPHelper.php

MCPHelper.php in FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration 2.2.1, at app/Modules/MCP/Helpers/MCPHelper.php

409 lines 15.0 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\Modules\MCP\Helpers;
4
5 use DateTimeImmutable;
6 use Exception;
7 use FluentBoards\App\Models\Board;
8 use FluentBoards\App\Models\Stage;
9 use FluentBoards\App\Models\Task;
10 use FluentBoards\App\Services\Constant;
11 use FluentBoards\App\Services\DescriptionMarkdownConverter;
12 use FluentBoards\App\Services\Helper;
13 use FluentBoards\App\Services\PermissionManager;
14
15 /**
16 * Shared utilities for Fluent Boards MCP tools.
17 */
18 class MCPHelper
19 {
20 const TASK_HISTORY_LIMIT = 20;
21 const MAX_MARKDOWN_DESCRIPTION_LENGTH = 65535;
22
23 public static function error($code, $message, $data = [])
24 {
25 return new \WP_Error($code, $message, $data);
26 }
27
28 public static function normalizePagination($params, $defaultPerPage = 20, $maxPerPage = 100)
29 {
30 $page = isset($params['page']) ? absint($params['page']) : 1;
31 $perPage = isset($params['per_page']) ? absint($params['per_page']) : $defaultPerPage;
32
33 return [
34 'page' => max(1, $page),
35 'per_page' => max(1, min($maxPerPage, $perPage)),
36 ];
37 }
38
39 public static function sanitizeMarkdown($value)
40 {
41 $value = wp_check_invalid_utf8((string) $value);
42 $value = str_replace(["\r\n", "\r", "\0"], ["\n", "\n", ""], $value);
43 $value = self::descriptionToMarkdown($value);
44
45 if (strlen($value) <= self::MAX_MARKDOWN_DESCRIPTION_LENGTH) {
46 return $value;
47 }
48
49 if (function_exists('mb_strcut')) {
50 return mb_strcut($value, 0, self::MAX_MARKDOWN_DESCRIPTION_LENGTH);
51 }
52
53 return substr($value, 0, self::MAX_MARKDOWN_DESCRIPTION_LENGTH);
54 }
55
56 public static function resolveBoard($params)
57 {
58 $boardId = isset($params['board_id']) ? absint($params['board_id']) : 0;
59 if (!$boardId) {
60 return self::error('invalid_param', __('Provide board_id', 'fluent-boards'));
61 }
62
63 $board = Board::with(['stages', 'labels', 'users'])->find($boardId);
64 if (!$board) {
65 return self::error('not_found', __('Board not found', 'fluent-boards'), ['board_id' => $boardId]);
66 }
67
68 return $board;
69 }
70
71 public static function resolveTask($params)
72 {
73 $taskId = isset($params['task_id']) ? absint($params['task_id']) : 0;
74 $boardId = isset($params['board_id']) ? absint($params['board_id']) : 0;
75
76 if (!$taskId || !$boardId) {
77 return self::error('invalid_param', __('Provide board_id and task_id', 'fluent-boards'));
78 }
79
80 $task = Task::with(self::taskDetailRelations())->find($taskId);
81 if (!$task || (int) $task->board_id !== $boardId) {
82 return self::error('not_found', __('Task not found on this board', 'fluent-boards'), [
83 'board_id' => $boardId,
84 'task_id' => $taskId,
85 ]);
86 }
87
88 return $task;
89 }
90
91 public static function loadTaskDetails($task)
92 {
93 return $task->load(self::taskDetailRelations());
94 }
95
96 public static function taskDetailRelations()
97 {
98 return [
99 'board',
100 'stage',
101 'labels',
102 'assignees',
103 'watchers',
104 'comments' => function ($query) {
105 $query->without(['images', 'replies'])
106 ->orderBy('id', 'DESC')
107 ->limit(self::TASK_HISTORY_LIMIT);
108 },
109 'activities' => function ($query) {
110 $query->limit(self::TASK_HISTORY_LIMIT);
111 },
112 ];
113 }
114
115 public static function canReadBoard($boardId)
116 {
117 return PermissionManager::userHasBoardPermission(absint($boardId), 'GET');
118 }
119
120 public static function canWriteBoard($boardId)
121 {
122 return PermissionManager::userHasBoardPermission(absint($boardId), 'POST');
123 }
124
125 public static function assertStageBelongsToBoard($stageId, $boardId)
126 {
127 $stage = Stage::where('id', absint($stageId))
128 ->where('board_id', absint($boardId))
129 ->whereNull('archived_at')
130 ->first();
131
132 if (!$stage) {
133 return self::error('not_found', __('Stage not found on this board', 'fluent-boards'), [
134 'board_id' => absint($boardId),
135 'stage_id' => absint($stageId),
136 ]);
137 }
138
139 return $stage;
140 }
141
142 public static function formatBoardSummary($board)
143 {
144 return [
145 'id' => (int) $board->id,
146 'title' => $board->title,
147 'description' => self::descriptionToMarkdown($board->description),
148 'type' => $board->type,
149 'currency' => $board->currency,
150 'created_by' => (int) $board->created_by,
151 'archived_at' => self::toIso8601($board->archived_at),
152 'created_at' => self::toIso8601($board->created_at),
153 'updated_at' => self::toIso8601($board->updated_at),
154 'stages_count' => isset($board->stages) ? count($board->stages) : 0,
155 'labels_count' => isset($board->labels) ? count($board->labels) : 0,
156 'members_count' => isset($board->users) ? count($board->users) : 0,
157 ];
158 }
159
160 public static function formatBoard($board, $includeTasks = false)
161 {
162 $data = self::formatBoardSummary($board);
163 $data['stages'] = self::formatStageList($board->stages ?? []);
164 $data['labels'] = self::formatLabelList($board->labels ?? []);
165 $data['members'] = self::formatUserList($board->users ?? [], $board->id);
166
167 if ($includeTasks) {
168 $tasks = Task::with(['stage', 'labels', 'assignees'])
169 ->where('board_id', $board->id)
170 ->whereNull('parent_id')
171 ->whereNull('archived_at')
172 ->orderBy('stage_id', 'asc')
173 ->orderBy('position', 'asc')
174 ->limit(100)
175 ->get();
176 $data['tasks'] = self::formatTaskList($tasks);
177 $data['tasks_limited_to'] = 100;
178 }
179
180 return $data;
181 }
182
183 public static function formatTaskSummary($task)
184 {
185 return [
186 'id' => (int) $task->id,
187 'title' => $task->title,
188 'slug' => $task->slug,
189 'board_id' => (int) $task->board_id,
190 'stage_id' => (int) $task->stage_id,
191 'parent_id' => $task->parent_id ? (int) $task->parent_id : null,
192 'status' => $task->status,
193 'priority' => $task->priority,
194 'position' => isset($task->position) ? (float) $task->position : null,
195 'crm_contact_id' => $task->crm_contact_id ? (int) $task->crm_contact_id : null,
196 'comments_count' => isset($task->comments_count) ? (int) $task->comments_count : 0,
197 'due_at' => self::toIso8601($task->due_at),
198 'started_at' => self::toIso8601($task->started_at),
199 'last_completed_at' => self::toIso8601($task->last_completed_at),
200 'archived_at' => self::toIso8601($task->archived_at),
201 'created_at' => self::toIso8601($task->created_at),
202 'updated_at' => self::toIso8601($task->updated_at),
203 'stage' => $task->stage ? self::formatStage($task->stage) : null,
204 'labels' => self::formatLabelList($task->labels ?? []),
205 'assignees' => self::formatUserList($task->assignees ?? [], $task->board_id),
206 ];
207 }
208
209 public static function formatTask($task)
210 {
211 $data = self::formatTaskSummary($task);
212 $data['description'] = self::descriptionToMarkdown($task->description);
213 $data['settings'] = $task->settings;
214 $data['board'] = $task->board ? self::formatBoardSummary($task->board) : null;
215 $data['watchers'] = self::formatUserList($task->watchers ?? [], $task->board_id);
216 $data['comments'] = self::formatCommentList(self::limitItems($task->comments ?? [], self::TASK_HISTORY_LIMIT));
217 $data['comments_limited_to'] = self::TASK_HISTORY_LIMIT;
218 $data['activities'] = self::formatActivityList(self::limitItems($task->activities ?? [], self::TASK_HISTORY_LIMIT));
219 $data['activities_limited_to'] = self::TASK_HISTORY_LIMIT;
220
221 return $data;
222 }
223
224 public static function descriptionToMarkdown($description)
225 {
226 return DescriptionMarkdownConverter::normalize($description);
227 }
228
229 public static function formatTaskList($tasks)
230 {
231 $items = [];
232 foreach ($tasks as $task) {
233 $items[] = self::formatTaskSummary($task);
234 }
235 return $items;
236 }
237
238 public static function formatStage($stage)
239 {
240 return [
241 'id' => (int) $stage->id,
242 'board_id' => (int) $stage->board_id,
243 'title' => $stage->title,
244 'slug' => $stage->slug,
245 'position' => isset($stage->position) ? (float) $stage->position : null,
246 'default_task_status' => method_exists($stage, 'defaultTaskStatus') ? $stage->defaultTaskStatus() : 'open',
247 'archived_at' => self::toIso8601($stage->archived_at),
248 ];
249 }
250
251 public static function formatStageList($stages)
252 {
253 $items = [];
254 foreach ($stages as $stage) {
255 $items[] = self::formatStage($stage);
256 }
257 return $items;
258 }
259
260 public static function formatLabelList($labels)
261 {
262 $items = [];
263 foreach ($labels as $label) {
264 $items[] = [
265 'id' => (int) $label->id,
266 'board_id' => (int) $label->board_id,
267 'title' => $label->title,
268 'slug' => $label->slug,
269 'color' => $label->color,
270 'bg_color' => $label->bg_color,
271 'color_preset' => \FluentBoards\Framework\Support\Arr::get(
272 (array) $label->settings,
273 \FluentBoards\App\Services\Constant::LABEL_COLOR_PRESET_SETTING
274 ),
275 'position' => isset($label->position) ? (float) $label->position : null,
276 'archived_at' => self::toIso8601($label->archived_at),
277 ];
278 }
279 return $items;
280 }
281
282 public static function formatUserList($users, $boardId)
283 {
284 $items = [];
285 foreach ($users as $user) {
286 $name = trim((string) ($user->display_name ?? ''));
287 if (!$name) {
288 $name = trim((string) (($user->first_name ?? '') . ' ' . ($user->last_name ?? '')));
289 }
290
291 $items[] = [
292 'id' => isset($user->ID) ? (int) $user->ID : (int) ($user->id ?? 0),
293 'display_name' => $name,
294 'email' => $user->user_email ?? '',
295 'avatar' => '',
296 ];
297 }
298 // Reuse the board permission lookup across user lists in this request.
299 static $boardManagerResults = [];
300 $isBoardManager = null;
301 if (!current_user_can('list_users')) {
302 $cacheKey = get_current_blog_id() . ':' . get_current_user_id() . ':' . (int) $boardId;
303 if (!array_key_exists($cacheKey, $boardManagerResults)) {
304 $boardManagerResults[$cacheKey] = PermissionManager::isBoardManager($boardId);
305 }
306 $isBoardManager = $boardManagerResults[$cacheKey];
307 }
308 $sanitizedItems = Helper::sanitizeUsersArray($items, $boardId, $isBoardManager);
309 foreach ($sanitizedItems as $index => &$item) {
310 // Only generate avatars when the policy permits disclosure of the email.
311 if ($item['email'] !== '' && $item['email'] === $items[$index]['email']) {
312 $item['avatar'] = fluent_boards_user_avatar($item['email'], $item['display_name']);
313 }
314 }
315 unset($item);
316
317 return $sanitizedItems;
318 }
319
320 public static function formatCommentList($comments)
321 {
322 $items = [];
323 foreach ($comments as $comment) {
324 $items[] = [
325 'id' => (int) $comment->id,
326 'task_id' => (int) $comment->task_id,
327 'parent_id' => $comment->parent_id ? (int) $comment->parent_id : null,
328 'type' => $comment->type,
329 'privacy' => $comment->privacy,
330 'status' => $comment->status,
331 'author_name' => $comment->author_name,
332 'description' => $comment->description,
333 'created_by' => $comment->created_by ? (int) $comment->created_by : null,
334 'created_at' => self::toIso8601($comment->created_at),
335 ];
336 }
337 return $items;
338 }
339
340 public static function formatActivityList($activities)
341 {
342 $items = [];
343 foreach ($activities as $activity) {
344 $items[] = [
345 'id' => (int) $activity->id,
346 'object_id' => isset($activity->object_id) ? (int) $activity->object_id : null,
347 'object_type' => $activity->object_type ?? '',
348 'action' => $activity->action ?? '',
349 'description' => $activity->description ?? '',
350 'created_by' => isset($activity->created_by) ? (int) $activity->created_by : null,
351 'created_at' => self::toIso8601($activity->created_at ?? null),
352 ];
353 }
354 return $items;
355 }
356
357 public static function sanitizeIdArray($values)
358 {
359 return array_values(array_filter(array_map('absint', (array) $values)));
360 }
361
362 private static function limitItems($items, $limit)
363 {
364 if (is_array($items)) {
365 return array_slice($items, 0, $limit);
366 }
367
368 $limited = [];
369 foreach ($items as $item) {
370 if (count($limited) >= $limit) {
371 break;
372 }
373
374 $limited[] = $item;
375 }
376
377 return $limited;
378 }
379
380 public static function currentUser()
381 {
382 $userId = get_current_user_id();
383 $user = $userId ? get_user_by('ID', $userId) : null;
384
385 return [
386 'wp_user_id' => (int) $userId,
387 'name' => $user ? $user->display_name : null,
388 'email' => $user ? $user->user_email : null,
389 'is_wp_admin' => $user ? user_can($user, 'manage_options') : false,
390 'is_fluent_boards_admin' => PermissionManager::isAdmin($userId),
391 'can_create_boards' => PermissionManager::userHasBoardCreationPermission($userId),
392 ];
393 }
394
395 public static function toIso8601($value)
396 {
397 if (!$value) {
398 return null;
399 }
400
401 try {
402 // Stored wall times use the site timezone; explicit offsets remain authoritative.
403 return (new DateTimeImmutable((string) $value, wp_timezone()))->format('c');
404 } catch (Exception $e) {
405 return (string) $value;
406 }
407 }
408 }
409