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

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

230 lines 7.7 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 /**
6 * Dependency-free HTML → Markdown converter for the one-time description
7 * migration (see docs/plan/description-markdown-migration.md).
8 *
9 * Legacy task/board descriptions were authored with the WP editor and stored as
10 * HTML; the Milkdown editor stores markdown. This converts the common subset of
11 * tags the old editor emitted (headings, emphasis, links, images, lists, quotes,
12 * code, rules). It is intentionally self-contained rather than pulling a composer
13 * dependency into a distributed WP plugin. Anything it converts imperfectly is
14 * recoverable because the migration keeps the original HTML backup in meta.
15 */
16 class DescriptionMarkdownConverter
17 {
18 /**
19 * Cheap heuristic mirroring the frontend `looksLikeHtml` helper: does the
20 * string contain markup the legacy editor would have produced?
21 */
22 public static function looksLikeHtml($str): bool
23 {
24 if (!is_string($str) || $str === '') {
25 return false;
26 }
27
28 return (bool) preg_match(
29 '/<(\/?)(p|div|br|span|ul|ol|li|h[1-6]|img|a|table|pre|blockquote|strong|em|code)\b[^>]*>/i',
30 $str
31 );
32 }
33
34 /**
35 * Convert an HTML description to markdown. Plain text / already-markdown
36 * input is returned trimmed and unchanged.
37 */
38 public static function convert($html): string
39 {
40 if (!is_string($html) || trim($html) === '') {
41 return '';
42 }
43
44 if (!self::looksLikeHtml($html)) {
45 return trim($html);
46 }
47
48 $dom = new \DOMDocument();
49 $previous = libxml_use_internal_errors(true);
50
51 // Force UTF-8 and wrap in a known root so we can walk a single subtree.
52 $loaded = $dom->loadHTML(
53 '<?xml encoding="UTF-8"?><div id="__fbs_root__">' . $html . '</div>',
54 LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
55 );
56
57 libxml_clear_errors();
58 libxml_use_internal_errors($previous);
59
60 if (!$loaded) {
61 // Fall back to a tag strip rather than losing the content entirely.
62 return trim(wp_strip_all_tags($html));
63 }
64
65 $root = $dom->getElementById('__fbs_root__');
66 $markdown = $root ? self::renderChildren($root) : '';
67
68 // Normalise excessive blank lines and trailing spaces.
69 $markdown = preg_replace("/[ \t]+\n/", "\n", $markdown);
70 $markdown = preg_replace("/\n{3,}/", "\n\n", $markdown);
71
72 return trim($markdown);
73 }
74
75 /**
76 * Normalize mixed legacy HTML / markdown descriptions to markdown without
77 * letting converter failures break API or MCP responses.
78 */
79 public static function normalize($description): string
80 {
81 if (!is_string($description) || trim($description) === '') {
82 return '';
83 }
84
85 if (!self::looksLikeHtml($description)) {
86 return $description;
87 }
88
89 try {
90 return self::convert($description);
91 } catch (\Throwable $e) {
92 return self::fallbackToPlainText($description);
93 }
94 }
95
96 private static function fallbackToPlainText($html): string
97 {
98 $html = preg_replace('/<(br|\/p|\/div|\/li|\/h[1-6]|\/blockquote|\/tr)\b[^>]*>/i', "\n", $html);
99 $html = preg_replace('/<(p|div|li|h[1-6]|blockquote|tr)\b[^>]*>/i', "\n", $html);
100 $text = function_exists('wp_strip_all_tags')
101 ? wp_strip_all_tags($html)
102 : strip_tags($html);
103
104 $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
105 $text = preg_replace("/[ \t]+\n/", "\n", $text);
106 $text = preg_replace("/\n{3,}/", "\n\n", $text);
107
108 return trim($text);
109 }
110
111 private static function renderChildren(\DOMNode $node): string
112 {
113 $out = '';
114 foreach ($node->childNodes as $child) {
115 $out .= self::renderNode($child);
116 }
117 return $out;
118 }
119
120 private static function renderNode(\DOMNode $node): string
121 {
122 if ($node->nodeType === XML_TEXT_NODE) {
123 // Collapse runs of whitespace (HTML is whitespace-insensitive).
124 return preg_replace('/\s+/', ' ', $node->nodeValue);
125 }
126
127 if ($node->nodeType !== XML_ELEMENT_NODE) {
128 return '';
129 }
130
131 $tag = strtolower($node->nodeName);
132
133 switch ($tag) {
134 case 'h1': return "\n\n# " . trim(self::renderChildren($node)) . "\n\n";
135 case 'h2': return "\n\n## " . trim(self::renderChildren($node)) . "\n\n";
136 case 'h3': return "\n\n### " . trim(self::renderChildren($node)) . "\n\n";
137 case 'h4': return "\n\n#### " . trim(self::renderChildren($node)) . "\n\n";
138 case 'h5': return "\n\n##### " . trim(self::renderChildren($node)) . "\n\n";
139 case 'h6': return "\n\n###### ". trim(self::renderChildren($node)) . "\n\n";
140
141 case 'p':
142 case 'div':
143 $inner = trim(self::renderChildren($node));
144 return $inner === '' ? '' : "\n\n" . $inner . "\n\n";
145
146 case 'br':
147 return " \n";
148
149 case 'strong':
150 case 'b':
151 return '**' . self::renderChildren($node) . '**';
152
153 case 'em':
154 case 'i':
155 return '*' . self::renderChildren($node) . '*';
156
157 case 'del':
158 case 's':
159 case 'strike':
160 return '~~' . self::renderChildren($node) . '~~';
161
162 case 'code':
163 // Inline code (code inside <pre> is handled by the 'pre' branch).
164 if ($node->parentNode && strtolower($node->parentNode->nodeName) === 'pre') {
165 return self::renderChildren($node);
166 }
167 return '`' . self::textContent($node) . '`';
168
169 case 'pre':
170 return "\n\n```\n" . rtrim(self::textContent($node)) . "\n```\n\n";
171
172 case 'blockquote':
173 $inner = trim(self::renderChildren($node));
174 $quoted = preg_replace('/^/m', '> ', $inner);
175 return "\n\n" . $quoted . "\n\n";
176
177 case 'hr':
178 return "\n\n---\n\n";
179
180 case 'a':
181 $href = $node->getAttribute('href');
182 $text = self::renderChildren($node);
183 if ($href === '') {
184 return $text;
185 }
186 return '[' . $text . '](' . $href . ')';
187
188 case 'img':
189 $src = $node->getAttribute('src');
190 $alt = $node->getAttribute('alt');
191 return $src === '' ? '' : '![' . $alt . '](' . $src . ')';
192
193 case 'ul':
194 case 'ol':
195 return "\n\n" . self::renderList($node, $tag === 'ol') . "\n";
196
197 case 'li':
198 // Handled by renderList; render inline if reached directly.
199 return self::renderChildren($node);
200
201 default:
202 return self::renderChildren($node);
203 }
204 }
205
206 private static function renderList(\DOMNode $node, bool $ordered): string
207 {
208 $out = '';
209 $index = 1;
210 foreach ($node->childNodes as $child) {
211 if ($child->nodeType !== XML_ELEMENT_NODE || strtolower($child->nodeName) !== 'li') {
212 continue;
213 }
214
215 $marker = $ordered ? ($index . '. ') : '- ';
216 $content = trim(self::renderChildren($child));
217 // Indent wrapped/nested lines under the marker.
218 $content = preg_replace("/\n/", "\n" . str_repeat(' ', strlen($marker)), $content);
219 $out .= $marker . $content . "\n";
220 $index++;
221 }
222 return $out;
223 }
224
225 private static function textContent(\DOMNode $node): string
226 {
227 return $node->textContent;
228 }
229 }
230