PluginProbe
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration / 2.0.10
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration v2.0.10
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 1.40 1.41 All 42 releases
fluent-boards / app / Hooks / Handlers / DescriptionMigrationHandler.php

DescriptionMigrationHandler.php in FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration 2.0.10, at app/Hooks/Handlers/DescriptionMigrationHandler.php

193 lines 6.5 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\Hooks\Handlers;
4
5 use FluentBoards\App\Services\DescriptionMarkdownConverter;
6
7 /**
8 * One-time backfill that converts legacy HTML task/board descriptions to
9 * markdown (see docs/plan/description-markdown-migration.md).
10 *
11 * Design guarantees:
12 * - Silent: writes the description column with raw $wpdb, so no model events,
13 * no `fluent_boards/task_updated` activity log, and no `updated_at` change
14 * (fbs_tasks.updated_at has no ON UPDATE clause).
15 * - Safe: the original HTML is backed up to meta (`_legacy_description_html`)
16 * before it is overwritten, and can be restored.
17 * - Idempotent: each processed row is flagged (`_description_format=markdown`);
18 * flagged rows are never re-fetched.
19 *
20 * This is the reusable migration engine only — no trigger is wired yet. Drive it
21 * from whatever mechanism you choose (admin action, upgrade routine, background
22 * job) by calling processBatch() or runToCompletion().
23 */
24 class DescriptionMigrationHandler
25 {
26 const FLAG_KEY = '_description_format';
27 const BACKUP_KEY = '_legacy_description_html';
28 const BATCH_SIZE = 50;
29
30 /**
31 * Convert up to $limit pending rows of the given type. Returns rows processed.
32 */
33 public function processBatch($type, $limit = self::BATCH_SIZE, $dryRun = false)
34 {
35 $rows = $this->fetchPending($type, $limit);
36 foreach ($rows as $row) {
37 $this->migrateRow($type, (int) $row->id, $row->description, $dryRun);
38 }
39 return count($rows);
40 }
41
42 /**
43 * Loop batches of both types to completion. Returns totals per type.
44 */
45 public function runToCompletion($dryRun = false)
46 {
47 $totals = ['task' => 0, 'board' => 0];
48 foreach (['task', 'board'] as $type) {
49 do {
50 $count = $this->processBatch($type, self::BATCH_SIZE, $dryRun);
51 $totals[$type] += $count;
52 } while ($count === self::BATCH_SIZE);
53 }
54 return $totals;
55 }
56
57 public function countPending($type)
58 {
59 global $wpdb;
60 [$table, $metaTable, $joinCond, $idCol] = $this->tableMap($type);
61
62 // phpcs:ignore WordPress.DB -- one-time migration count over own tables
63 return (int) $wpdb->get_var(
64 "SELECT COUNT(*) FROM {$table} t
65 LEFT JOIN {$metaTable} m ON {$joinCond} AND m.`key` = '" . self::FLAG_KEY . "'
66 WHERE t.description IS NOT NULL AND t.description <> '' AND m.id IS NULL"
67 );
68 }
69
70 private function fetchPending($type, $limit)
71 {
72 global $wpdb;
73 [$table, $metaTable, $joinCond, $idCol] = $this->tableMap($type);
74
75 // phpcs:ignore WordPress.DB -- one-time migration read over own tables
76 return $wpdb->get_results($wpdb->prepare(
77 "SELECT t.{$idCol} AS id, t.description FROM {$table} t
78 LEFT JOIN {$metaTable} m ON {$joinCond} AND m.`key` = '" . self::FLAG_KEY . "'
79 WHERE t.description IS NOT NULL AND t.description <> '' AND m.id IS NULL
80 LIMIT %d",
81 $limit
82 ));
83 }
84
85 private function migrateRow($type, $id, $description, $dryRun)
86 {
87 $isHtml = DescriptionMarkdownConverter::looksLikeHtml($description);
88 $markdown = $isHtml ? DescriptionMarkdownConverter::convert($description) : trim((string) $description);
89
90 if ($dryRun) {
91 return;
92 }
93
94 // Only legacy HTML rows need a backup + column rewrite; already-markdown
95 // rows are simply flagged so they are not re-examined.
96 if ($isHtml) {
97 $this->writeMeta($type, $id, self::BACKUP_KEY, $description);
98 $this->updateDescription($type, $id, $markdown);
99 }
100
101 $this->writeMeta($type, $id, self::FLAG_KEY, 'markdown');
102 }
103
104 private function updateDescription($type, $id, $markdown)
105 {
106 global $wpdb;
107 $table = $type === 'board' ? $wpdb->prefix . 'fbs_boards' : $wpdb->prefix . 'fbs_tasks';
108
109 // Raw update: bypasses model events / activity hooks and leaves updated_at intact.
110 $wpdb->update($table, ['description' => $markdown], ['id' => $id]);
111 }
112
113 private function writeMeta($type, $id, $key, $value)
114 {
115 global $wpdb;
116 $now = current_time('mysql');
117
118 if ($type === 'board') {
119 $wpdb->insert($wpdb->prefix . 'fbs_meta', [
120 'object_id' => $id,
121 'object_type' => 'board',
122 'key' => $key,
123 'value' => $value,
124 'created_at' => $now,
125 'updated_at' => $now,
126 ]);
127 } else {
128 $wpdb->insert($wpdb->prefix . 'fbs_task_metas', [
129 'task_id' => $id,
130 'key' => $key,
131 'value' => $value,
132 'created_at' => $now,
133 'updated_at' => $now,
134 ]);
135 }
136 }
137
138 /**
139 * Restore original HTML from backups (rollback for a bad migration).
140 * Returns rows restored per type.
141 */
142 public function restoreAll()
143 {
144 global $wpdb;
145 $totals = ['task' => 0, 'board' => 0];
146
147 // Tasks
148 $rows = $wpdb->get_results($wpdb->prepare(
149 "SELECT task_id AS id, value FROM {$wpdb->prefix}fbs_task_metas WHERE `key` = %s",
150 self::BACKUP_KEY
151 ));
152 foreach ($rows as $row) {
153 $wpdb->update($wpdb->prefix . 'fbs_tasks', ['description' => $row->value], ['id' => $row->id]);
154 $totals['task']++;
155 }
156
157 // Boards
158 $rows = $wpdb->get_results($wpdb->prepare(
159 "SELECT object_id AS id, value FROM {$wpdb->prefix}fbs_meta WHERE object_type = 'board' AND `key` = %s",
160 self::BACKUP_KEY
161 ));
162 foreach ($rows as $row) {
163 $wpdb->update($wpdb->prefix . 'fbs_boards', ['description' => $row->value], ['id' => $row->id]);
164 $totals['board']++;
165 }
166
167 return $totals;
168 }
169
170 /**
171 * @return array{0:string,1:string,2:string,3:string} [table, metaTable, joinCond, idCol]
172 */
173 private function tableMap($type)
174 {
175 global $wpdb;
176 if ($type === 'board') {
177 return [
178 $wpdb->prefix . 'fbs_boards',
179 $wpdb->prefix . 'fbs_meta',
180 "m.object_id = t.id AND m.object_type = 'board'",
181 'id',
182 ];
183 }
184
185 return [
186 $wpdb->prefix . 'fbs_tasks',
187 $wpdb->prefix . 'fbs_task_metas',
188 'm.task_id = t.id',
189 'id',
190 ];
191 }
192 }
193