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 / Services / FluentBoardsImporter.php

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

883 lines 32.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\Framework\Support\Arr;
6 use FluentBoards\App\Models\Activity;
7 use FluentBoards\App\Models\Board;
8 use FluentBoards\App\Models\Comment;
9 use FluentBoards\App\Models\Relation;
10 use FluentBoards\App\Models\Task;
11 use FluentBoards\App\Models\TaskMeta;
12 use FluentBoards\App\Models\User;
13 use FluentBoards\App\Services\Constant;
14
15 /**
16 * Class FluentBoardsImporter
17 *
18 * Handles the importing of FluentBoards data, including boards, stages, labels, tasks, subtasks, and comments.
19 *
20 * @package FluentBoards\App\Services
21 */
22 class FluentBoardsImporter
23 {
24 private static $siteUrl;
25
26 /**
27 * @param array $data The full import data.
28 * @return array ['board' => Board, 'mappers' => [...], 'siteUrl' => string]
29 */
30 public static function initializeBoard($data)
31 {
32 if (ini_get('max_execution_time') < 300) {
33 ini_set('max_execution_time', '300');
34 }
35
36 $boardData = self::sanitizeBoardData($data['board']);
37
38 $stages = Arr::get($data['board'], 'stages', []);
39 $labels = Arr::get($data['board'], 'labels', []);
40 $users = Arr::get($data['board'], 'users', []);
41 $customFields = Arr::get($data['board'], 'custom_fields', []);
42 self::$siteUrl = $data['site_url'];
43
44 $board = Board::create($boardData);
45 $stageMapper = self::stageCreate($board, $stages);
46
47 if (self::$siteUrl == site_url('/')) {
48 self::createMembers($board, $users, self::loadImportedUsers($users));
49 }
50
51 $labelMapper = self::labelCreate($board, $labels);
52 $customFieldMapper = self::customFieldCreate($board, $customFields);
53
54 return [
55 'board' => $board,
56 'mappers' => [
57 'stages' => $stageMapper,
58 'labels' => $labelMapper,
59 'customFields' => $customFieldMapper,
60 ],
61 'siteUrl' => $data['site_url'],
62 ];
63 }
64
65 /**
66 * @param Board $board The board to add tasks to.
67 * @param array $mappers ['stages' => [...], 'labels' => [...], 'customFields' => [...]]
68 * @param string $siteUrl The source site URL.
69 * @param array $tasks The batch of tasks to process.
70 */
71 public static function processTaskBatch($board, $mappers, $siteUrl, $tasks)
72 {
73 if (ini_get('max_execution_time') < 300) {
74 ini_set('max_execution_time', '300');
75 }
76
77 self::$siteUrl = $siteUrl;
78 $usersByEmail = self::loadImportedUsers([], $tasks);
79 self::createTasks($board, $mappers['stages'], $mappers['labels'], $mappers['customFields'], $tasks, $usersByEmail);
80 }
81
82 /**
83 * @param int $boardId The board ID.
84 * @return Board|null
85 */
86 public static function finalizeImport($boardId)
87 {
88 $board = Board::find($boardId);
89 if ($board) {
90 self::createActivity($board);
91 self::updateBoardTaskCount($boardId);
92 }
93 return $board;
94 }
95
96 /**
97 * @param array $data The data to import.
98 * @return Board The imported board.
99 */
100 public static function process($data)
101 {
102 // Increase max execution time to 5 minutes if less than 5 minutes
103 if (ini_get('max_execution_time') < 300) {
104 ini_set('max_execution_time', '300');
105 }
106
107 $boardData = self::sanitizeBoardData($data['board']);
108
109 $stages = Arr::get($data['board'], 'stages', []);
110 $labels = Arr::get($data['board'], 'labels', []);
111 $tasks = Arr::get($data['board'], 'tasks', []);
112 $users = Arr::get($data['board'], 'users', []);
113 $customFields = Arr::get($data['board'], 'custom_fields', []);
114 self::$siteUrl = $data['site_url'];
115
116 $usersByEmail = self::loadImportedUsers($users, $tasks);
117
118 // Create Board
119 $board = Board::create($boardData);
120 // Create stages
121 $listStageMapper = self::stageCreate($board, $stages);
122
123 //Create Members if import in same site
124 if (self::$siteUrl == site_url('/'))
125 {
126 self::createMembers($board, $users, $usersByEmail);
127 }
128
129 // Create labels
130 $labelMapper = self::labelCreate($board, $labels);
131
132 $customFieldMapper = self::customFieldCreate($board, $customFields);
133
134 self::createTasks($board, $listStageMapper, $labelMapper, $customFieldMapper, $tasks, $usersByEmail);
135
136 self::createActivity($board);
137 self::updateBoardTaskCount($board->id);
138
139 return $board;
140 }
141
142 /**
143 * Create stages for a given board.
144 *
145 * @param Board $board The board to create stages for.
146 * @param array $stages The stages to create.
147 * @return array A mapping of old stage IDs to new stage IDs.
148 */
149 private static function stageCreate($board, $stages)
150 {
151 $listStageMapper = [];
152 foreach ($stages as $stage) {
153 $stageData = self::sanitizeStageAndLabelData($stage);
154 $newStage = $board->stages()->create($stageData);
155 $listStageMapper[(int)$stage['id']] = $newStage->id;
156 }
157 return $listStageMapper;
158 }
159
160 /** Fetch all referenced local users once, including authors and both subtask formats. */
161 private static function loadImportedUsers($members, $tasks = [])
162 {
163 $emails = array_column($members, 'user_email');
164 while ($tasks) {
165 $task = array_pop($tasks);
166 foreach (['assignees', 'watchers'] as $relation) {
167 $emails = array_merge($emails, array_column($task[$relation] ?? [], 'user_email'));
168 }
169 $emails = array_merge($emails, array_column($task['comments'] ?? [], 'author_email'));
170 foreach ($task['subtask_group'] ?? [] as $group) {
171 $tasks = array_merge($tasks, $group['subtasks'] ?? []);
172 }
173 $tasks = array_merge($tasks, $task['subtasks'] ?? []);
174 }
175
176 $emails = array_values(array_unique(array_filter(array_map([self::class, 'importedEmailKey'], $emails))));
177 $usersByEmail = [];
178 if ($emails) {
179 foreach (User::whereIn('user_email', $emails)->get(['ID', 'user_email']) as $user) {
180 $usersByEmail[self::importedEmailKey($user->user_email)] = $user;
181 }
182 }
183
184 return $usersByEmail;
185 }
186
187 /** Use a consistent email key for exported and local user records. */
188 private static function importedEmailKey($email)
189 {
190 return strtolower(sanitize_email($email));
191 }
192
193 /** Restore membership using matched local accounts and validated board roles. */
194 private static function createMembers($board, $users, $usersByEmail)
195 {
196 if (!$board) {
197 return false;
198 }
199
200 $members = [];
201 foreach ($users as $user) {
202 $boardMember = $usersByEmail[self::importedEmailKey($user['user_email'])] ?? null;
203
204 if(!$boardMember) {
205 continue;
206 }
207
208 $settings = self::sanitizeImportedMemberSettings($user['pivot']['settings'] ?? []);
209 if ($settings === null) {
210 continue;
211 }
212
213 $members[$boardMember->ID] = [
214 'object_type' => Constant::OBJECT_TYPE_BOARD_USER,
215 'settings' => maybe_serialize($settings),
216 'preferences' => $user['pivot']['preferences']
217 ];
218 }
219 if ($members) {
220 $board->users()->attach($members);
221 }
222 }
223
224 /** Accept only member, admin, or viewer role flags, including legacy serialized exports. */
225 private static function sanitizeImportedMemberSettings($settings)
226 {
227 if (is_string($settings)) {
228 // Reject enum tokens before decoding: allowed_classes does not prevent enum autoloading.
229 if (!is_serialized($settings) || preg_match('/(?:^|[;{}])E:[0-9]+:"/s', $settings)) {
230 return null;
231 }
232 $settings = @unserialize($settings, ['allowed_classes' => false, 'max_depth' => 5]);
233 }
234
235 if (!is_array($settings)) {
236 return null;
237 }
238
239 $role = [];
240 foreach ([Constant::IS_BOARD_ADMIN, 'is_viewer_only'] as $flag) {
241 $value = $settings[$flag] ?? false;
242 if (!in_array($value, [true, false, 1, 0, '1', '0'], true)) {
243 return null;
244 }
245 $role[$flag] = (bool)$value;
246 }
247
248 // A membership cannot be both administrator and viewer-only.
249 return $role[Constant::IS_BOARD_ADMIN] && $role['is_viewer_only'] ? null : $role;
250 }
251
252 /**
253 * Create labels for a given board.
254 *
255 * @param Board $board The board to create labels for.
256 * @param array $labels The labels to create.
257 * @return array A mapping of old label IDs to new label IDs.
258 */
259 private static function labelCreate($board, $labels)
260 {
261 $labelMapper = [];
262 foreach ($labels as $label) {
263 $labelData = self::sanitizeStageAndLabelData($label);
264 $newLabel = $board->labels()->create($labelData);
265 $labelMapper[(string)$label['id']] = $newLabel->id;
266 }
267 return $labelMapper;
268 }
269
270 /** Create custom field definitions and map their imported IDs. */
271 private static function customFieldCreate($board, $customFields)
272 {
273 $customFieldMapper = [];
274 foreach ($customFields as $customField)
275 {
276 $customFieldData = self::sanitizeStageAndLabelData($customField);
277 $newCustomField = $board->customFields()->create($customFieldData);
278 $customFieldMapper[(string)$customField['id']] = $newCustomField->id;
279 }
280
281 return $customFieldMapper;
282 }
283
284 /**
285 * Create tasks and restore their users from the shared batch map.
286 *
287 * @param Board $board The board to create tasks for.
288 * @param array $listStageMapper A mapping of old stage IDs to new stage IDs.
289 * @param array $labelMapper A mapping of old label IDs to new label IDs.
290 * @param array $tasks The tasks to create.
291 */
292 private static function createTasks($board, $listStageMapper, $labelMapper, $customFieldMapper, $tasks, $usersByEmail)
293 {
294 foreach ($tasks as $task) {
295 try {
296 $task['stage_id'] = $listStageMapper[(int) $task['stage_id']] ??
297 reset($listStageMapper); // if task is missing the stage id then it included in first stage
298 $task['board_id'] = $board->id;
299 $taskData = self::sanitizeTaskData($task);
300 $newTask = new Task();
301 $newTask->title = $taskData['title'];
302 $newTask->slug = $taskData['slug'];
303 $newTask->parent_id = $taskData['parent_id'];
304 $newTask->type = $taskData['type'];
305 $newTask->status = $taskData['status'];
306 $newTask->stage_id = $taskData['stage_id'];
307 $newTask->source = $taskData['source'];
308 $newTask->source_id = $taskData['source_id'];
309 $newTask->priority = $taskData['priority'];
310 $newTask->description = $taskData['description'];
311 $newTask->lead_value = $taskData['lead_value'];
312 $newTask->position = $taskData['position'];
313 $newTask->issue_number = $taskData['issue_number'];
314 $newTask->reminder_type = $taskData['reminder_type'];
315 $newTask->remind_at = $taskData['remind_at'];
316 $newTask->started_at = $taskData['started_at'];
317 $newTask->due_at = $taskData['due_at'];
318 $newTask->last_completed_at = $taskData['last_completed_at'];
319 $newTask->archived_at = $taskData['archived_at'];
320 $newTask->settings = $taskData['settings'];
321 $newTask->board_id = $taskData['board_id'];
322 $newTask->save();
323 self::restoreTimeEstimate($task, $newTask);
324
325 $templateMeta = $task['meta']['is_template'] ?? 'no';
326 $newTask->updateMeta('is_template', $templateMeta);
327 // map labels
328 self::associateLabelsWithTask($task['labels'], $newTask,
329 $labelMapper);
330
331 //map customFields
332 self::associateCustomFieldsWithTask($task['custom_fields'],
333 $newTask, $customFieldMapper);
334
335 if (self::$siteUrl == site_url('/')) {
336 // attach assignees
337 self::attachAssigneeToTask($task['assignees'], $newTask, $usersByEmail);
338 //attach wathers
339 self::attachWatchersToTask($task['watchers'], $newTask, $usersByEmail);
340 }
341
342 // Create subtasks
343 if (!empty($task['subtask_group'])) {
344 self::addSubtasks($task['subtask_group'], $newTask, $board, $usersByEmail);
345 } else if (!empty($task['subtasks'])) {
346 self::addSubtasksFromOldJson($task['subtasks'], $newTask, $board, $usersByEmail);
347 }
348 // Create comments
349 self::createComments($task['comments'], $newTask, $usersByEmail);
350 } catch (\Exception $e) {
351 error_log('Error while importing task: old '. $task['id'] . ' new: '. $newTask->id. $e->getMessage());
352 continue;
353 }
354 }
355 }
356
357 /**
358 * Add grouped subtasks using the shared batch user map.
359 *
360 * @param array $subtasks The subtasks to add.
361 * @param Task $task The task to add subtasks to.
362 * @param Board $board The board to associate the subtasks with.
363 */
364 private static function addSubtasks($subtaskGroups, $task, $board, $usersByEmail)
365 {
366 $subTaskCounter = 0;
367 $subTaskCompletedCounter = 0;
368 foreach ($subtaskGroups as $group) {
369 $subtaskGroup = self::createSubtaskGroup($task, $group);
370 foreach ($group['subtasks'] as $subtask) {
371 $subtask['stage_id'] = $task->stage_id;
372 $subtask['parent_id'] = $task->id;
373 $subtask['board_id'] = $board->id;
374 $subTaskData = self::sanitizeTaskData($subtask);
375 $newSubtask = $board->tasks()->create($subTaskData);
376 self::restoreTimeEstimate($subtask, $newSubtask);
377 self::attachAssigneeToTask($subtask['assignees'], $newSubtask, $usersByEmail);
378 $subTaskCounter++;
379 if ($newSubtask['status'] == 'closed') {
380 $subTaskCompletedCounter++;
381 }
382 TaskMeta::create([
383 'task_id' => $newSubtask->id,
384 'key' => Constant::SUBTASK_GROUP_CHILD,
385 'value' => $subtaskGroup->id
386 ]);
387 }
388 }
389 $taskSettings = $task->settings ?? [];
390 $taskSettings['subtask_count'] = $subTaskCounter;
391 $taskSettings['subtask_completed_count'] = $subTaskCompletedCounter;
392 $task->settings = $taskSettings;
393 $task->save();
394 }
395
396 //if someone want to import a board from old version's .json file,
397 // there are some code repetitions, but will improve later
398 /** Restore legacy subtasks and assignees using the shared batch user map. */
399 private static function addSubtasksFromOldJson($subtasks, $task, $board, $usersByEmail)
400 {
401 $subTaskCounter = 0;
402 $subTaskCompletedCounter = 0;
403 $subtaskGroup = self::createSubtaskGroup($task);
404
405 foreach ($subtasks as $subtask) {
406 $subtask['stage_id'] = $task->stage_id;
407 $subtask['parent_id'] = $task->id;
408 $subtask['board_id'] = $board->id;
409 $subTaskData = self::sanitizeTaskData($subtask);
410 $newSubtask = $board->tasks()->create($subTaskData);
411 self::restoreTimeEstimate($subtask, $newSubtask);
412 self::attachAssigneeToTask($subtask['assignees'], $newSubtask, $usersByEmail);
413 $subTaskCounter++;
414 if ($newSubtask['status'] == 'closed') {
415 $subTaskCompletedCounter++;
416 }
417 TaskMeta::create([
418 'task_id' => $newSubtask->id,
419 'key' => Constant::SUBTASK_GROUP_CHILD,
420 'value' => $subtaskGroup->id
421 ]);
422 }
423 $taskSettings = $task->settings ?? [];
424 $taskSettings['subtask_count'] = $subTaskCounter;
425 $taskSettings['subtask_completed_count'] = $subTaskCompletedCounter;
426 $task->settings = $taskSettings;
427 $task->save();
428 }
429
430 /** Restore nonnegative integer estimates without copying unrelated task metadata. */
431 private static function restoreTimeEstimate($data, $task)
432 {
433 $value = $data['meta'][Constant::TASK_ESTIMATED_MINUTES] ?? null;
434 if (!is_int($value) && !is_string($value)) {
435 return;
436 }
437
438 $minutes = filter_var($value, FILTER_VALIDATE_INT, ['options' => ['min_range' => 0]]);
439 if ($minutes !== false) {
440 $task->updateMeta(Constant::TASK_ESTIMATED_MINUTES, $minutes);
441 }
442 }
443
444 /** Find or create the imported subtask group. */
445 private static function createSubtaskGroup($task, $subtaskGroup = null)
446 {
447 if (!$subtaskGroup) {
448 return TaskMeta::create([
449 'task_id' => $task->id,
450 'key' => Constant::SUBTASK_GROUP_NAME,
451 'value' => 'Default Subtask Group'
452 ]);
453 }
454 $taskMeta = TaskMeta::where('task_id', $task->id)
455 ->where('key', Constant::SUBTASK_GROUP_NAME)
456 ->where('value', $subtaskGroup['value'])
457 ->first();
458 if (!$taskMeta) {
459 return TaskMeta::create([
460 'task_id' => $task->id,
461 'key' => Constant::SUBTASK_GROUP_NAME,
462 'value' => $subtaskGroup['value']
463 ]);
464 }
465
466 return $taskMeta;
467 }
468
469 /**
470 * Create comments and replies using creators resolved once for the batch.
471 *
472 * @param array $commentsAndReply The comments and replies to create.
473 * @param Task $task The task to associate the comments with.
474 */
475 private static function createComments($commentsAndReply, $task, $usersByEmail)
476 {
477 $comments = [];
478 $replies = [];
479 $commentMapper = [];
480 $commentCounter = 0;
481
482 foreach ($commentsAndReply as $data) {
483 $commentCreator = $usersByEmail[self::importedEmailKey($data['author_email'])] ?? null;
484 if (!$commentCreator) {
485 continue;
486 }
487 $data['created_by'] = $commentCreator->ID;
488 $data['task_id'] = $task->id;
489 $data['board_id'] = $task->board_id;
490 if ($data['type'] == 'reply') {
491 $replies[] = $data;
492 } else {
493 $comments[] = $data;
494 }
495 }
496
497 foreach ($comments as $comment) {
498 $commentData = self::sanitizeCommentAndReply($comment);
499 $newComment = Comment::create($commentData);
500 $commentMapper[(int)$comment['id']] = $newComment->id;
501 $commentCounter++;
502 }
503 foreach ($replies as $reply) {
504 $replyData = self::sanitizeCommentAndReply($reply);
505 if(!$commentMapper[(int)$reply['parent_id']]) {
506 $reply['type'] = 'comment';
507 Comment::create($replyData);
508 $commentCounter++;
509 } else {
510 $replyData['parent_id'] = $commentMapper[(int)$reply['parent_id']];
511 Comment::create($replyData);
512 }
513 }
514
515 $task['comments_count'] = $commentCounter;
516 $task->save();
517 }
518
519 /**
520 * Create an activity log for a given board.
521 *
522 * @param Board $board The board to create an activity log for.
523 */
524 private static function createActivity($board)
525 {
526 Activity::create([
527 'created_by' => get_current_user_id(),
528 'action' => 'created',
529 'object_id' => $board->id,
530 'object_type' => Constant::ACTIVITY_BOARD,
531 'old_value' => null,
532 'new_value' => $board->title,
533 'column' => 'board',
534 'description' => 'imported from FluentBoards',
535 ]);
536 }
537
538 /**
539 * Sanitize Board data
540 *
541 * @param array $data The board data to sanitize.
542 * @return array The sanitized board data.
543 */
544 private static function sanitizeBoardData($data)
545 {
546 $boardSettings = [];
547 $boardBackground = [];
548 foreach ($data['settings'] as $key => $value) {
549 $boardSettings[$key] = sanitize_text_field($value);
550 }
551
552 $background = is_array($data['background'] ?? null) ? $data['background'] : [];
553
554 foreach ($background as $key => $value) {
555 switch ($key) {
556 case 'image_url':
557 $boardBackground['image_url'] = sanitize_url($value);
558 break;
559 case 'id':
560 $boardBackground['id'] = sanitize_text_field($value);
561 break;
562 case 'is_image':
563 $boardBackground['is_image'] = rest_sanitize_boolean($value);
564 break;
565 case 'color':
566 $boardBackground['color'] = sanitize_text_field($value);
567 break;
568 default:
569 break;
570 }
571 }
572
573 return [
574 'title' => sanitize_text_field($data['title']),
575 'description' => wp_kses_post($data['description'] ?? ''),
576 'type' => sanitize_text_field($data['type']),
577 'parent_id' => intval($data['parent_id']),
578 'currency' => sanitize_text_field($data['currency']),
579 'background' => $boardBackground,
580 'settings' => $boardSettings,
581 ];
582 }
583
584 /**
585 * Sanitize Stage and Label data
586 *
587 * @param array $data The stage or label data to sanitize.
588 * @return array The sanitized data.
589 */
590 private static function sanitizeStageAndLabelData($data)
591 {
592 $settings = [];
593 if (isset($data['settings'])) {
594 foreach ($data['settings'] as $key => $value) {
595 if ($key == 'is_template') {
596 $settings[$key] = rest_sanitize_boolean($value);
597 } else if ($key == 'default_task_assignees') {
598 $settings[$key] = [];
599 } else if ($key == Constant::LABEL_COLOR_PRESET_SETTING) {
600 $presetId = sanitize_key($value);
601 if (Constant::getLabelColorPreset($presetId)) {
602 $settings[$key] = $presetId;
603 }
604 } else {
605 $settings[$key] = $value ? sanitize_text_field($value) : null;
606 }
607 }
608 }
609
610 return [
611 'title' => sanitize_text_field($data['title']),
612 'slug' => sanitize_text_field($data['slug']),
613 'type' => sanitize_text_field($data['type']),
614 'position' => $data['position'] ? intval($data['position']) : null,
615 'color' => $data['color'] ? sanitize_text_field($data['color']) : null,
616 'bg_color' => $data['bg_color'] ? sanitize_text_field($data['bg_color']) : null,
617 'archived_at' => $data['archived_at'] ? sanitize_text_field($data['archived_at']) : null,
618 'settings' => $settings,
619 ];
620 }
621
622 /**
623 * Sanitize task and subtask data without retaining counts for unsupported attachments.
624 *
625 * @param array $data The task or subtask data to sanitize.
626 * @return array The sanitized data.
627 */
628 private static function sanitizeTaskData($data)
629 {
630 $settings = [];
631 if (isset($data['settings'])) {
632 foreach ($data['settings'] as $key => $value) {
633 if ($key == 'cover') {
634 $cover = [];
635 foreach ($value as $coverKey => $coverValue) {
636 $cover[$coverKey] = $coverValue ? sanitize_text_field($coverValue) : null;
637 }
638 $settings[$key] = $cover;
639 } else {
640 $settings[$key] = $value ? sanitize_text_field($value) : null;
641 }
642 }
643 }
644
645
646 // JSON imports do not restore attachments, so the source count is not valid here.
647 $settings['attachment_count'] = 0;
648
649 return [
650 'board_id' => intval($data['board_id']),
651 'title' => sanitize_text_field($data['title']),
652 'slug' => sanitize_text_field($data['slug']),
653 'parent_id' => $data['parent_id'] ? intval($data['parent_id']) : null,
654 'type' => $data['type'] ? sanitize_text_field($data['type']) : 'task',
655 'status' => $data['status'] ? sanitize_text_field($data['status']) : 'open',
656 'stage_id' => intval($data['stage_id']),
657 'source' => sanitize_text_field($data['source']),
658 'source_id' => $data['source_id'] ? intval($data['source_id']) : null,
659 'priority' => sanitize_text_field($data['priority'] ?? ''),
660 'description' => wp_kses_post($data['description'] ?? ''),
661 'lead_value' => $data['lead_value'] ? doubleval($data['lead_value']) : null,
662 'position' => intval($data['position']),
663 'issue_number' => $data['issue_number'] ? intval($data['issue_number']) : null,
664 'reminder_type' => $data['reminder_type'] ? sanitize_text_field($data['reminder_type']) : null,
665 'remind_at' => $data['remind_at'] ? sanitize_text_field($data['remind_at']) : null,
666 'started_at' => $data['started_at'] ? sanitize_text_field($data['started_at']) : null,
667 'due_at' => $data['due_at'] ? sanitize_text_field($data['due_at']) : null,
668 'last_completed_at' => $data['last_completed_at'] ? sanitize_text_field($data['last_completed_at']) : null,
669 'archived_at' => $data['archived_at'] ? sanitize_text_field($data['archived_at']) : null,
670 'settings' => $settings,
671 ];
672 }
673
674
675 /**
676 * Sanitize Comment and Reply data
677 *
678 * This method takes an array of comment or reply data and sanitizes each field to ensure it is safe for storage and display.
679 *
680 * @param array $data The comment or reply data to sanitize.
681 * @return array The sanitized data.
682 */
683 private static function sanitizeCommentAndReply($data)
684 {
685 return [
686 'task_id' => intval($data['task_id']),
687 'board_id' => intval($data['board_id']),
688 'description' => wp_kses_post($data['description'] ?? ''),
689 'type' => sanitize_text_field($data['type']),
690 'privacy' => sanitize_text_field($data['privacy']),
691 'status' => sanitize_text_field($data['status']),
692 'author_name' => sanitize_text_field($data['author_name']),
693 'author_email' => sanitize_email($data['author_email']),
694 'author_ip' => sanitize_text_field($data['author_ip']),
695 'created_by' => intval($data['created_by']),
696 'settings' => $data['settings'],
697 ];
698 }
699
700
701 /**
702 * Update the task count for a given board.
703 *
704 * @param int $boardId The ID of the board to update.
705 */
706 private static function updateBoardTaskCount($boardId)
707 {
708 $board = Board::find($boardId);
709 $settings = $board->settings ?? [];
710 $settings['tasks_count'] = $board->tasks()->where('parent_id', null)->whereNull('archived_at')->count();
711 $board->settings = $settings;
712 $board->save();
713 }
714
715 /**
716 * Associate labels with a given task.
717 *
718 * @param array $taskAssociatedLabels The labels to associate.
719 * @param Task $task The task to associate labels with.
720 * @param array $labelMapper A mapping of label IDs to new label IDs.
721 */
722 private static function associateLabelsWithTask($taskAssociatedLabels, $task, $labelMapper)
723 {
724 foreach ($taskAssociatedLabels as $label) {
725 Relation::create([
726 'object_id' => $task->id,
727 'object_type' => Constant::OBJECT_TYPE_TASK_LABEL,
728 'foreign_id' => $labelMapper[(string)$label['id']]
729 ]);
730 }
731 }
732
733 /** Restore sanitized custom field values using the imported ID map. */
734 private static function associateCustomFieldsWithTask($taskCustomFields, $task, $customFieldMapper)
735 {
736 foreach ($taskCustomFields as $customField) {
737 $customFieldId = (string)intval($customField['id'] ?? 0);
738
739 if (!$customFieldId || !isset($customFieldMapper[$customFieldId])) {
740 continue;
741 }
742
743 $settings = self::sanitizeImportedCustomFieldSettings(
744 $customField['pivot']['settings'] ?? null
745 );
746
747 if ($settings === null) {
748 continue;
749 }
750
751 Relation::create([
752 'object_id' => $task->id,
753 'object_type' => Constant::TASK_CUSTOM_FIELD,
754 'foreign_id' => $customFieldMapper[$customFieldId],
755 'settings' => $settings
756 ]);
757 }
758 }
759
760 /**
761 * Decode and sanitize custom-field settings from an imported board.
762 *
763 * Legacy exports store these settings as serialized arrays. Object creation
764 * must remain disabled because the import document is attacker-controlled.
765 *
766 * @param mixed $settings
767 * @return array|null
768 */
769 private static function sanitizeImportedCustomFieldSettings($settings)
770 {
771 if (is_string($settings)) {
772 $settings = trim($settings);
773
774 if (!is_serialized($settings)) {
775 return null;
776 }
777
778 // PHP does not apply allowed_classes to enum tokens and may autoload
779 // the attacker-selected enum class before returning from unserialize().
780 if (preg_match('/(?:^|[;{}])E:[0-9]+:"/s', $settings)) {
781 return null;
782 }
783
784 $settings = @unserialize($settings, [
785 'allowed_classes' => false,
786 'max_depth' => 20,
787 ]);
788 }
789
790 if (!is_array($settings) || !array_key_exists('value', $settings)) {
791 return null;
792 }
793
794 if (!self::isValidImportedCustomFieldValue($settings['value'])) {
795 return null;
796 }
797
798 return [
799 'value' => self::sanitizeImportedCustomFieldValue($settings['value']),
800 ];
801 }
802
803 /**
804 * Ensure an imported custom-field value contains only bounded primitive data.
805 *
806 * @param mixed $value
807 * @param int $depth
808 * @return bool
809 */
810 private static function isValidImportedCustomFieldValue($value, $depth = 0)
811 {
812 if ($depth > 20) {
813 return false;
814 }
815
816 if (is_array($value)) {
817 foreach ($value as $item) {
818 if (!self::isValidImportedCustomFieldValue($item, $depth + 1)) {
819 return false;
820 }
821 }
822
823 return true;
824 }
825
826 return is_null($value) || is_string($value) || is_bool($value) ||
827 is_int($value) || is_float($value);
828 }
829
830 /**
831 * Recursively sanitize strings within a validated custom-field value.
832 *
833 * @param mixed $value
834 * @return mixed
835 */
836 private static function sanitizeImportedCustomFieldValue($value)
837 {
838 if (is_array($value)) {
839 $sanitizedValue = [];
840
841 foreach ($value as $key => $item) {
842 $sanitizedKey = is_string($key) ? sanitize_text_field($key) : $key;
843 $sanitizedValue[$sanitizedKey] = self::sanitizeImportedCustomFieldValue($item);
844 }
845
846 return $sanitizedValue;
847 }
848
849 return is_string($value) ? sanitize_text_field($value) : $value;
850 }
851
852 /** Restore task assignees from the batch map with one pivot insert. */
853 private static function attachAssigneeToTask($assignees, $task, $usersByEmail)
854 {
855 $members = [];
856 foreach ($assignees as $assignee) {
857 $user = $usersByEmail[self::importedEmailKey($assignee['user_email'])] ?? null;
858 if ($user) {
859 $members[$user->ID] = ['object_type' => Constant::OBJECT_TYPE_TASK_ASSIGNEE];
860 }
861 }
862 if ($members) {
863 $task->assignees()->attach($members);
864 }
865 }
866
867 /** Restore task watchers from the batch map with one pivot insert. */
868 private static function attachWatchersToTask($watchers, $task, $usersByEmail)
869 {
870 $members = [];
871 foreach ($watchers as $watcher) {
872 $user = $usersByEmail[self::importedEmailKey($watcher['user_email'])] ?? null;
873 if ($user) {
874 $members[$user->ID] = ['object_type' => Constant::OBJECT_TYPE_USER_TASK_WATCH];
875 }
876 }
877 if ($members) {
878 $task->watchers()->attach($members);
879 }
880 }
881
882 }
883