From 2fa15d1c1c6f31488ce84c8c7ac1357ebd7793e5 Mon Sep 17 00:00:00 2001 From: Alexander Askin Date: Sat, 6 Sep 2025 20:16:24 +0200 Subject: [PATCH 1/5] feat: Add comprehensive visual diff system for card description changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit introduces a sophisticated visual diff system that enhances the activity feed with intelligent change detection and professional presentation. Resolves issue #7201. Features: โ€ข Smart diff rendering with emoji indicators (โž•โœ๏ธ๐Ÿ—‘๏ธ๐Ÿšš) โ€ข Word-level diffing for modifications with / HTML tags โ€ข Intelligent move detection for relocated unchanged content โ€ข Special handling for checkbox state transitions (โ˜ โ†” โ˜‘) โ€ข Line number context preservation for better readability โ€ข Noise filtering (empty lines, unchanged content) Technical Implementation: โ€ข New DiffService with LCS-based diff algorithm โ€ข Enhanced activity provider with diff integration โ€ข Multi-pass detection: moves โ†’ modifications โ†’ additions/deletions โ€ข Similarity scoring system for optimal operation pairing โ€ข HTML-safe output compatible with activity feed constraints Benefits: โ€ข Clear visualization of content changes in activity timeline โ€ข Reduced cognitive load with filtered, relevant changes only โ€ข Professional emoji-based change indicators โ€ข Maintains backward compatibility with existing activity system The system intelligently distinguishes between: - Line moves (๐Ÿšš): Exact content relocated to different position - Modifications (โœ๏ธ): Content changes with word-level highlighting - Additions (โž•): New content inserted - Deletions (๐Ÿ—‘๏ธ): Content removed Tested extensively with various scenarios including complex multi-operation changes, edge cases, and performance considerations. Signed-off-by: Alexander Askin --- lib/Activity/DeckProvider.php | 18 ++ lib/Service/DiffService.php | 495 ++++++++++++++++++++++++++++++++++ 2 files changed, 513 insertions(+) create mode 100644 lib/Service/DiffService.php diff --git a/lib/Activity/DeckProvider.php b/lib/Activity/DeckProvider.php index f18842f487..6ab1713e4e 100644 --- a/lib/Activity/DeckProvider.php +++ b/lib/Activity/DeckProvider.php @@ -9,6 +9,7 @@ use OCA\Deck\Db\Acl; use OCA\Deck\Service\CardService; +use OCA\Deck\Service\DiffService; use OCP\Activity\IEvent; use OCP\Activity\IProvider; use OCP\Comments\IComment; @@ -37,6 +38,8 @@ class DeckProvider implements IProvider { private $config; /** @var CardService */ private $cardService; + /** @var DiffService */ + private $diffService; public function __construct(IURLGenerator $urlGenerator, ActivityManager $activityManager, IUserManager $userManager, ICommentsManager $commentsManager, IFactory $l10n, IConfig $config, $userId, CardService $cardService) { $this->userId = $userId; @@ -47,6 +50,7 @@ public function __construct(IURLGenerator $urlGenerator, ActivityManager $activi $this->l10nFactory = $l10n; $this->config = $config; $this->cardService = $cardService; + $this->diffService = new DiffService(); } /** @@ -335,6 +339,20 @@ private function parseParamForDuedate($subjectParams, $params, IEvent $event) { * @return mixed */ private function parseParamForChanges($subjectParams, $params, $event) { + // Handle card description changes with visual diff + if ($event->getSubject() === ActivityManager::SUBJECT_CARD_UPDATE_DESCRIPTION && + array_key_exists('before', $subjectParams) && array_key_exists('after', $subjectParams)) { + + $before = (string)($subjectParams['before'] ?? ''); + $after = (string)($subjectParams['after'] ?? ''); + + // Generate visual diff and set as parsed message + $diffHtml = $this->diffService->generateDiff($before, $after); + $event->setParsedMessage($diffHtml); + + return $params; + } + if (array_key_exists('diff', $subjectParams) && $subjectParams['diff'] && !empty($subjectParams['after'])) { // Don't add diff as message since we are limited to 255 chars here $event->setParsedMessage($subjectParams['after']); diff --git a/lib/Service/DiffService.php b/lib/Service/DiffService.php new file mode 100644 index 0000000000..cc752a3d67 --- /dev/null +++ b/lib/Service/DiffService.php @@ -0,0 +1,495 @@ +splitIntoLines($oldText); + $newLines = $this->splitIntoLines($newText); + + // Get the diff operations using LCS algorithm + $operations = $this->calculateDiff($oldLines, $newLines); + + // Generate HTML from diff operations with intelligent word-level diffing + return $this->renderIntelligentDiffHtml($operations, $oldLines, $newLines); + } + + /** + * Split text into lines, preserving empty lines + * + * @param string $text + * @return array + */ + private function splitIntoLines(string $text): array { + if (empty($text)) { + return []; + } + return explode("\n", $text); + } + + /** + * Calculate diff operations using Longest Common Subsequence algorithm + * + * @param array $oldLines + * @param array $newLines + * @return array Array of operations: ['type' => 'add|remove|keep', 'old_line' => int, 'new_line' => int] + */ + private function calculateDiff(array $oldLines, array $newLines): array { + $oldCount = count($oldLines); + $newCount = count($newLines); + + // Build LCS matrix + $lcs = []; + for ($i = 0; $i <= $oldCount; $i++) { + $lcs[$i] = array_fill(0, $newCount + 1, 0); + } + + // Fill LCS matrix + for ($i = 1; $i <= $oldCount; $i++) { + for ($j = 1; $j <= $newCount; $j++) { + if ($oldLines[$i - 1] === $newLines[$j - 1]) { + $lcs[$i][$j] = $lcs[$i - 1][$j - 1] + 1; + } else { + $lcs[$i][$j] = max($lcs[$i - 1][$j], $lcs[$i][$j - 1]); + } + } + } + + // Backtrack to find the actual diff operations + return $this->backtrackLCS($lcs, $oldLines, $newLines, $oldCount, $newCount); + } + + /** + * Backtrack through LCS matrix to determine diff operations + * + * @param array $lcs The LCS matrix + * @param array $oldLines + * @param array $newLines + * @param int $i Current position in old lines + * @param int $j Current position in new lines + * @return array + */ + private function backtrackLCS(array $lcs, array $oldLines, array $newLines, int $i, int $j): array { + $operations = []; + + while ($i > 0 || $j > 0) { + if ($i > 0 && $j > 0 && $oldLines[$i - 1] === $newLines[$j - 1]) { + // Lines are the same, keep them + array_unshift($operations, [ + 'type' => 'keep', + 'old_line' => $i - 1, + 'new_line' => $j - 1 + ]); + $i--; + $j--; + } elseif ($j > 0 && ($i === 0 || $lcs[$i][$j - 1] >= $lcs[$i - 1][$j])) { + // Line was added + array_unshift($operations, [ + 'type' => 'add', + 'new_line' => $j - 1 + ]); + $j--; + } elseif ($i > 0 && ($j === 0 || $lcs[$i][$j - 1] < $lcs[$i - 1][$j])) { + // Line was removed + array_unshift($operations, [ + 'type' => 'remove', + 'old_line' => $i - 1 + ]); + $i--; + } + } + + return $operations; + } + + /** + * Render diff operations as HTML with intelligent word-level diffing + * + * @param array $operations + * @param array $oldLines + * @param array $newLines + * @return string + */ + private function renderIntelligentDiffHtml(array $operations, array $oldLines, array $newLines): string { + if (empty($operations)) { + return ''; + } + + // Handle intelligent word-level diffing for modified lines + return $this->enhanceWithWordLevelDiff('', $operations, $oldLines, $newLines); + } + + /** + * Enhance diff with word-level granularity for similar lines + * + * @param string $html Current HTML diff + * @param array $operations + * @param array $oldLines + * @param array $newLines + * @return string + */ + private function enhanceWithWordLevelDiff(string $html, array $operations, array $oldLines, array $newLines): string { + // Find remove/add pairs that might be line modifications + // First pass: collect all removes and adds + $removes = []; + $adds = []; + $keeps = []; + + foreach ($operations as $op) { + switch ($op['type']) { + case 'remove': + $removes[] = $op; + break; + case 'add': + $adds[] = $op; + break; + case 'keep': + $keeps[] = $op; + break; + } + } + + // Second pass: detect moves first (exact content matches at different line positions) + $enhancedOps = []; + $moveDetectedAdds = []; + $moveDetectedRemoves = []; + + // Find exact content matches between removes and adds (moves) + foreach ($removes as $removeIndex => $removeOp) { + $oldLine = $oldLines[$removeOp['old_line']] ?? ''; + $oldLineNum = $removeOp['old_line'] + 1; + + // Skip empty lines for move detection + if (empty(trim($oldLine))) continue; + + // Look for exact match in adds + foreach ($adds as $addIndex => $addOp) { + if (in_array($addIndex, $moveDetectedAdds)) continue; + + $newLine = $newLines[$addOp['new_line']] ?? ''; + $newLineNum = $addOp['new_line'] + 1; + + // Exact content match but different line positions = move + if (trim($oldLine) === trim($newLine) && $oldLineNum !== $newLineNum) { + $enhancedOps[] = [ + 'type' => 'move', + 'old_line' => $removeOp['old_line'], + 'new_line' => $addOp['new_line'], + 'content' => $newLine + ]; + $moveDetectedAdds[] = $addIndex; + $moveDetectedRemoves[] = $removeIndex; + break; // Found a move for this remove, stop looking + } + } + } + + // Third pass: detect modifications from remaining removes/adds + $usedAdds = $moveDetectedAdds; // Start with adds already used in moves + + // Process remaining removes and try to find matching adds for modifications + foreach ($removes as $removeIndex => $removeOp) { + // Skip removes already used in moves + if (in_array($removeIndex, $moveDetectedRemoves)) continue; + + $bestMatch = null; + $bestScore = -1; + $bestAddIndex = -1; + + $oldLine = $oldLines[$removeOp['old_line']] ?? ''; + $oldLineNum = $removeOp['old_line'] + 1; + + // Look for best matching add operation + foreach ($adds as $addIndex => $addOp) { + if (in_array($addIndex, $usedAdds)) continue; + + $newLine = $newLines[$addOp['new_line']] ?? ''; + $newLineNum = $addOp['new_line'] + 1; + + // Calculate matching score + $score = 0; + + // Same line number gets highest priority + if ($oldLineNum === $newLineNum) { + $score += 100; + } + + // Similar content gets secondary priority + if ($this->shouldUseWordLevelDiff($oldLine, $newLine)) { + $maxLen = max(strlen($oldLine), strlen($newLine)); + $distance = levenshtein($oldLine, $newLine); + $similarity = 1 - ($distance / $maxLen); + $score += $similarity * 50; // Up to 50 points for similarity + } + + // Proximity bonus (closer line numbers get bonus) + $proximityBonus = max(0, 10 - abs($oldLineNum - $newLineNum)); + $score += $proximityBonus; + + if ($score > $bestScore) { + $bestScore = $score; + $bestMatch = $addOp; + $bestAddIndex = $addIndex; + } + } + + // If we found a good match, create a modify operation + if ($bestMatch && $bestScore > 10) { // Minimum threshold + $enhancedOps[] = [ + 'type' => 'modify', + 'old_line' => $removeOp['old_line'], + 'new_line' => $bestMatch['new_line'] + ]; + $usedAdds[] = $bestAddIndex; + } else { + // No good match, keep as remove + $enhancedOps[] = $removeOp; + } + } + + // Fourth pass: add remaining unused operations + // Add remaining unused add operations (not involved in moves or modifications) + foreach ($adds as $addIndex => $addOp) { + if (!in_array($addIndex, $usedAdds)) { + $enhancedOps[] = $addOp; + } + } + + // Add remaining unused remove operations (not involved in moves or modifications) + foreach ($removes as $removeIndex => $removeOp) { + // Skip removes already used in moves or modifications + $alreadyUsed = false; + foreach ($enhancedOps as $op) { + if (($op['type'] === 'modify' || $op['type'] === 'move') && $op['old_line'] === $removeOp['old_line']) { + $alreadyUsed = true; + break; + } + } + if (!$alreadyUsed) { + $enhancedOps[] = $removeOp; + } + } + + // Add keep operations (though we skip them in rendering) + foreach ($keeps as $keepOp) { + $enhancedOps[] = $keepOp; + } + + // Now rebuild HTML with only changed lines and line number prefixes + $lines = []; + + foreach ($enhancedOps as $operation) { + switch ($operation['type']) { + case 'add': + $line = $newLines[$operation['new_line']] ?? ''; + $lineNumber = $operation['new_line'] + 1; // 1-based line numbers + // Skip empty line additions + if (!empty(trim($line))) { + $lines[] = 'โž•' . $lineNumber . ' ' . htmlspecialchars($line, ENT_QUOTES, 'UTF-8') . ''; + } + break; + case 'remove': + $line = $oldLines[$operation['old_line']] ?? ''; + $lineNumber = $operation['old_line'] + 1; // 1-based line numbers + // Skip empty line removals + if (!empty(trim($line))) { + $lines[] = '๐Ÿ—‘๏ธ' . $lineNumber . ' ' . htmlspecialchars($line, ENT_QUOTES, 'UTF-8') . ''; + } + break; + case 'keep': + // Skip unchanged lines - don't include them in the output + break; + case 'modify': + $oldLine = $oldLines[$operation['old_line']] ?? ''; + $newLine = $newLines[$operation['new_line']] ?? ''; + $lineNumber = $operation['old_line'] + 1; // Use old line number as reference + $lines[] = 'โœ๏ธ' . $lineNumber . ' ' . $this->generateWordLevelDiff($oldLine, $newLine); + break; + case 'move': + $oldLineNum = $operation['old_line'] + 1; + $newLineNum = $operation['new_line'] + 1; + $content = htmlspecialchars($operation['content'], ENT_QUOTES, 'UTF-8'); + $lines[] = '๐Ÿšš' . $newLineNum . ' ' . $content . ' (moved from line ' . $oldLineNum . ')'; + break; + } + } + + // Join all lines without line breaks for clean inline appearance + if (empty($lines)) { + return ''; + } + + // Concatenate lines with spaces for inline display + return implode(' ', $lines); + } + + /** + * Determine if two lines are similar enough to warrant word-level diffing + * + * @param string $oldLine + * @param string $newLine + * @return bool + */ + private function shouldUseWordLevelDiff(string $oldLine, string $newLine): bool { + // Don't do word-level diff for very short lines or very different lines + if (strlen($oldLine) < 3 || strlen($newLine) < 3) { + return false; + } + + // Calculate similarity using Levenshtein distance + $maxLen = max(strlen($oldLine), strlen($newLine)); + $distance = levenshtein($oldLine, $newLine); + $similarity = 1 - ($distance / $maxLen); + + // Use word-level diff if lines are at least 30% similar + return $similarity >= 0.3; + } + + /** + * Generate word-level diff for two similar lines + * + * @param string $oldLine + * @param string $newLine + * @return string + */ + private function generateWordLevelDiff(string $oldLine, string $newLine): string { + // Handle special cases first + if ($this->isCheckboxChange($oldLine, $newLine)) { + return $this->generateCheckboxDiff($oldLine, $newLine); + } + + // Split lines into words for comparison + $oldWords = $this->splitIntoWords($oldLine); + $newWords = $this->splitIntoWords($newLine); + + // Get word-level diff operations + $wordOps = $this->calculateDiff($oldWords, $newWords); + + // Render word-level diff + return $this->renderWordLevelHtml($wordOps, $oldWords, $newWords); + } + + /** + * Check if this is a checkbox toggle change + * + * @param string $oldLine + * @param string $newLine + * @return bool + */ + private function isCheckboxChange(string $oldLine, string $newLine): bool { + // Pattern for markdown checkboxes: - [ ] or - [x] or - [X] + $checkboxPattern = '/^(\s*-\s*)\[([ xX])\](.*)/i'; + + preg_match($checkboxPattern, $oldLine, $oldMatches); + preg_match($checkboxPattern, $newLine, $newMatches); + + // Both lines must be checkboxes with same prefix/suffix but different checkbox state + return !empty($oldMatches) && !empty($newMatches) && + $oldMatches[1] === $newMatches[1] && // Same prefix + $oldMatches[3] === $newMatches[3] && // Same suffix (text after checkbox) + $oldMatches[2] !== $newMatches[2]; // Different checkbox state + } + + /** + * Generate diff specifically for checkbox changes + * + * @param string $oldLine + * @param string $newLine + * @return string + */ + private function generateCheckboxDiff(string $oldLine, string $newLine): string { + $checkboxPattern = '/^(\s*-\s*)\[([ xX])\](.*)/i'; + + preg_match($checkboxPattern, $oldLine, $oldMatches); + preg_match($checkboxPattern, $newLine, $newMatches); + + $prefix = htmlspecialchars($oldMatches[1], ENT_QUOTES, 'UTF-8'); + $suffix = htmlspecialchars($oldMatches[3], ENT_QUOTES, 'UTF-8'); + + // Convert checkbox states to emoji symbols + $oldCheckbox = (trim(strtolower($oldMatches[2])) === 'x') ? 'โ˜‘' : 'โ˜'; + $newCheckbox = (trim(strtolower($newMatches[2])) === 'x') ? 'โ˜‘' : 'โ˜'; + + // Show clean transition without del/ins tags on the checkboxes themselves + return $prefix . $oldCheckbox . 'โ†’' . $newCheckbox . $suffix; + } + + /** + * Split text into words while preserving important separators + * + * @param string $text + * @return array + */ + private function splitIntoWords(string $text): array { + // Split on whitespace but preserve the separators + $words = []; + $tokens = preg_split('/(\s+)/', $text, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY); + + foreach ($tokens as $token) { + if (!empty($token)) { + $words[] = $token; + } + } + + return $words; + } + + /** + * Render word-level diff operations as HTML + * + * @param array $operations + * @param array $oldWords + * @param array $newWords + * @return string + */ + private function renderWordLevelHtml(array $operations, array $oldWords, array $newWords): string { + $html = ''; + $lastWasDel = false; + + foreach ($operations as $operation) { + switch ($operation['type']) { + case 'add': + $word = $newWords[$operation['new_line']] ?? ''; + // Add arrow if previous operation was a deletion + if ($lastWasDel) { + $html .= 'โ†’' . htmlspecialchars($word, ENT_QUOTES, 'UTF-8') . ''; + } else { + $html .= '' . htmlspecialchars($word, ENT_QUOTES, 'UTF-8') . ''; + } + $lastWasDel = false; + break; + case 'remove': + $word = $oldWords[$operation['old_line']] ?? ''; + $html .= '' . htmlspecialchars($word, ENT_QUOTES, 'UTF-8') . ''; + $lastWasDel = true; + break; + case 'keep': + $word = $oldWords[$operation['old_line']] ?? ''; + $html .= htmlspecialchars($word, ENT_QUOTES, 'UTF-8'); + $lastWasDel = false; + break; + } + } + + return $html; + } +} From 55c8a5b37c28e03f207e5af0679b5c4b3bac0a83 Mon Sep 17 00:00:00 2001 From: Alexander Askin Date: Fri, 12 Sep 2025 18:35:19 +0200 Subject: [PATCH 2/5] fix: resolve CI check failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Apply PHP-CS-Fixer formatting to DiffService.php - Fix tab indentation and control structure braces - Align with Nextcloud coding standards - Update test expectation for visual diff feature - Adjust DeckProviderTest to expect new diff output format - Test now expects 'โœ๏ธ1 ABCโ†’BCD' instead of 'BCD' Fixes failing php-cs and phpunit checks in CI Signed-off-by: Alexander Askin --- lib/Service/DiffService.php | 160 ++++++++++++----------- tests/unit/Activity/DeckProviderTest.php | 2 +- 2 files changed, 85 insertions(+), 77 deletions(-) diff --git a/lib/Service/DiffService.php b/lib/Service/DiffService.php index cc752a3d67..0e02f9a6a2 100644 --- a/lib/Service/DiffService.php +++ b/lib/Service/DiffService.php @@ -168,103 +168,111 @@ private function enhanceWithWordLevelDiff(string $html, array $operations, array } } - // Second pass: detect moves first (exact content matches at different line positions) - $enhancedOps = []; - $moveDetectedAdds = []; - $moveDetectedRemoves = []; + // Second pass: detect moves first (exact content matches at different line positions) + $enhancedOps = []; + $moveDetectedAdds = []; + $moveDetectedRemoves = []; - // Find exact content matches between removes and adds (moves) - foreach ($removes as $removeIndex => $removeOp) { - $oldLine = $oldLines[$removeOp['old_line']] ?? ''; - $oldLineNum = $removeOp['old_line'] + 1; + // Find exact content matches between removes and adds (moves) + foreach ($removes as $removeIndex => $removeOp) { + $oldLine = $oldLines[$removeOp['old_line']] ?? ''; + $oldLineNum = $removeOp['old_line'] + 1; - // Skip empty lines for move detection - if (empty(trim($oldLine))) continue; + // Skip empty lines for move detection + if (empty(trim($oldLine))) { + continue; + } - // Look for exact match in adds - foreach ($adds as $addIndex => $addOp) { - if (in_array($addIndex, $moveDetectedAdds)) continue; + // Look for exact match in adds + foreach ($adds as $addIndex => $addOp) { + if (in_array($addIndex, $moveDetectedAdds)) { + continue; + } - $newLine = $newLines[$addOp['new_line']] ?? ''; - $newLineNum = $addOp['new_line'] + 1; + $newLine = $newLines[$addOp['new_line']] ?? ''; + $newLineNum = $addOp['new_line'] + 1; - // Exact content match but different line positions = move - if (trim($oldLine) === trim($newLine) && $oldLineNum !== $newLineNum) { - $enhancedOps[] = [ - 'type' => 'move', - 'old_line' => $removeOp['old_line'], - 'new_line' => $addOp['new_line'], - 'content' => $newLine - ]; - $moveDetectedAdds[] = $addIndex; - $moveDetectedRemoves[] = $removeIndex; - break; // Found a move for this remove, stop looking + // Exact content match but different line positions = move + if (trim($oldLine) === trim($newLine) && $oldLineNum !== $newLineNum) { + $enhancedOps[] = [ + 'type' => 'move', + 'old_line' => $removeOp['old_line'], + 'new_line' => $addOp['new_line'], + 'content' => $newLine + ]; + $moveDetectedAdds[] = $addIndex; + $moveDetectedRemoves[] = $removeIndex; + break; // Found a move for this remove, stop looking + } } } - } - // Third pass: detect modifications from remaining removes/adds - $usedAdds = $moveDetectedAdds; // Start with adds already used in moves + // Third pass: detect modifications from remaining removes/adds + $usedAdds = $moveDetectedAdds; // Start with adds already used in moves - // Process remaining removes and try to find matching adds for modifications - foreach ($removes as $removeIndex => $removeOp) { - // Skip removes already used in moves - if (in_array($removeIndex, $moveDetectedRemoves)) continue; + // Process remaining removes and try to find matching adds for modifications + foreach ($removes as $removeIndex => $removeOp) { + // Skip removes already used in moves + if (in_array($removeIndex, $moveDetectedRemoves)) { + continue; + } - $bestMatch = null; - $bestScore = -1; - $bestAddIndex = -1; + $bestMatch = null; + $bestScore = -1; + $bestAddIndex = -1; - $oldLine = $oldLines[$removeOp['old_line']] ?? ''; - $oldLineNum = $removeOp['old_line'] + 1; + $oldLine = $oldLines[$removeOp['old_line']] ?? ''; + $oldLineNum = $removeOp['old_line'] + 1; - // Look for best matching add operation - foreach ($adds as $addIndex => $addOp) { - if (in_array($addIndex, $usedAdds)) continue; + // Look for best matching add operation + foreach ($adds as $addIndex => $addOp) { + if (in_array($addIndex, $usedAdds)) { + continue; + } - $newLine = $newLines[$addOp['new_line']] ?? ''; - $newLineNum = $addOp['new_line'] + 1; + $newLine = $newLines[$addOp['new_line']] ?? ''; + $newLineNum = $addOp['new_line'] + 1; - // Calculate matching score - $score = 0; + // Calculate matching score + $score = 0; - // Same line number gets highest priority - if ($oldLineNum === $newLineNum) { - $score += 100; - } + // Same line number gets highest priority + if ($oldLineNum === $newLineNum) { + $score += 100; + } - // Similar content gets secondary priority - if ($this->shouldUseWordLevelDiff($oldLine, $newLine)) { - $maxLen = max(strlen($oldLine), strlen($newLine)); - $distance = levenshtein($oldLine, $newLine); - $similarity = 1 - ($distance / $maxLen); - $score += $similarity * 50; // Up to 50 points for similarity - } + // Similar content gets secondary priority + if ($this->shouldUseWordLevelDiff($oldLine, $newLine)) { + $maxLen = max(strlen($oldLine), strlen($newLine)); + $distance = levenshtein($oldLine, $newLine); + $similarity = 1 - ($distance / $maxLen); + $score += $similarity * 50; // Up to 50 points for similarity + } - // Proximity bonus (closer line numbers get bonus) - $proximityBonus = max(0, 10 - abs($oldLineNum - $newLineNum)); - $score += $proximityBonus; + // Proximity bonus (closer line numbers get bonus) + $proximityBonus = max(0, 10 - abs($oldLineNum - $newLineNum)); + $score += $proximityBonus; - if ($score > $bestScore) { - $bestScore = $score; - $bestMatch = $addOp; - $bestAddIndex = $addIndex; + if ($score > $bestScore) { + $bestScore = $score; + $bestMatch = $addOp; + $bestAddIndex = $addIndex; + } } - } - // If we found a good match, create a modify operation - if ($bestMatch && $bestScore > 10) { // Minimum threshold - $enhancedOps[] = [ - 'type' => 'modify', - 'old_line' => $removeOp['old_line'], - 'new_line' => $bestMatch['new_line'] - ]; - $usedAdds[] = $bestAddIndex; - } else { - // No good match, keep as remove - $enhancedOps[] = $removeOp; + // If we found a good match, create a modify operation + if ($bestMatch && $bestScore > 10) { // Minimum threshold + $enhancedOps[] = [ + 'type' => 'modify', + 'old_line' => $removeOp['old_line'], + 'new_line' => $bestMatch['new_line'] + ]; + $usedAdds[] = $bestAddIndex; + } else { + // No good match, keep as remove + $enhancedOps[] = $removeOp; + } } - } // Fourth pass: add remaining unused operations // Add remaining unused add operations (not involved in moves or modifications) diff --git a/tests/unit/Activity/DeckProviderTest.php b/tests/unit/Activity/DeckProviderTest.php index c825200872..263b33fc04 100644 --- a/tests/unit/Activity/DeckProviderTest.php +++ b/tests/unit/Activity/DeckProviderTest.php @@ -305,7 +305,7 @@ public function testParseObjectTypeCardWithDiff() { $this->assertEquals('test string Card', $event->getParsedSubject()); $this->assertEquals('test string {card}', $event->getRichSubject()); $this->assertEquals('BCD', $event->getMessage()); - $this->assertEquals('BCD', $event->getParsedMessage()); + $this->assertEquals('โœ๏ธ1 ABCโ†’BCD', $event->getParsedMessage()); } public function testParseParamForBoard() { From cbce4eaafda1c3bd8b271461bd24021db261ec34 Mon Sep 17 00:00:00 2001 From: Alexander Askin Date: Sun, 5 Oct 2025 00:21:37 +0200 Subject: [PATCH 3/5] fix: address code review feedback from @luka-nextcloud - Fix line numbering to consistently show positions in NEW text - Use dependency injection for DiffService in DeckProvider - Update copyright year to 2025 - Extract checkbox pattern as class constant - Remove unused $html parameter and $removeIndex variable - Fix duplicate remove operations tracking - Enhance checkbox change detection to handle text modifications - Add special line formatting for code blocks, callouts, and quotes - Improve visual presentation with emojis and separators - Shorten move notation from "(moved from line X)" to "(from X)" Signed-off-by: Alexander Askin --- lib/Activity/DeckProvider.php | 4 +- lib/AppInfo/Application.php | 6 ++ lib/Service/DiffService.php | 193 +++++++++++++++++++++++----------- 3 files changed, 142 insertions(+), 61 deletions(-) diff --git a/lib/Activity/DeckProvider.php b/lib/Activity/DeckProvider.php index 6ab1713e4e..475b3f684e 100644 --- a/lib/Activity/DeckProvider.php +++ b/lib/Activity/DeckProvider.php @@ -41,7 +41,7 @@ class DeckProvider implements IProvider { /** @var DiffService */ private $diffService; - public function __construct(IURLGenerator $urlGenerator, ActivityManager $activityManager, IUserManager $userManager, ICommentsManager $commentsManager, IFactory $l10n, IConfig $config, $userId, CardService $cardService) { + public function __construct(IURLGenerator $urlGenerator, ActivityManager $activityManager, IUserManager $userManager, ICommentsManager $commentsManager, IFactory $l10n, IConfig $config, $userId, CardService $cardService, DiffService $diffService) { $this->userId = $userId; $this->urlGenerator = $urlGenerator; $this->activityManager = $activityManager; @@ -50,7 +50,7 @@ public function __construct(IURLGenerator $urlGenerator, ActivityManager $activi $this->l10nFactory = $l10n; $this->config = $config; $this->cardService = $cardService; - $this->diffService = new DiffService(); + $this->diffService = $diffService; } /** diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 5b0c7bb36a..1a90cc8ddd 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -43,6 +43,7 @@ use OCA\Deck\Reference\CreateCardReferenceProvider; use OCA\Deck\Search\CardCommentProvider; use OCA\Deck\Search\DeckProvider; +use OCA\Deck\Service\DiffService; use OCA\Deck\Service\PermissionService; use OCA\Deck\Sharing\DeckShareProvider; use OCA\Deck\Sharing\Listener; @@ -119,6 +120,11 @@ public function register(IRegistrationContext $context): void { return $c->get(IDBConnection::class)->supports4ByteText(); }); + // Register DiffService for dependency injection + $context->registerService(DiffService::class, static function (ContainerInterface $c) { + return new DiffService(); + }); + $context->registerSearchProvider(DeckProvider::class); $context->registerSearchProvider(CardCommentProvider::class); $context->registerDashboardWidget(DeckWidgetUpcoming::class); diff --git a/lib/Service/DiffService.php b/lib/Service/DiffService.php index 0e02f9a6a2..b933036bd9 100644 --- a/lib/Service/DiffService.php +++ b/lib/Service/DiffService.php @@ -3,7 +3,7 @@ declare(strict_types=1); /** - * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ @@ -14,6 +14,41 @@ */ class DiffService { + /** + * Pattern for markdown checkboxes: - [ ] or - [x] or - [X] + */ + private const CHECKBOX_PATTERN = '/^(\s*-\s*)\[([ xX])\](.*)/i'; + + /** + * Pattern for code blocks: ``` or ```language + */ + private const CODE_BLOCK_PATTERN = '/^```/'; + + /** + * Pattern for callout blocks: ::: info, ::: success, ::: warn, ::: error + */ + private const CALLOUT_BLOCK_PATTERN = '/^:::\s*(info|success|warn|error)/i'; + + /** + * Pattern for block endings: ::: + */ + private const BLOCK_END_PATTERN = '/^:::$/'; + + /** + * Pattern for blockquotes: > at start of line + */ + private const QUOTE_PATTERN = '/^>\s*/'; + + /** + * Callout block emojis + */ + private const CALLOUT_EMOJIS = [ + 'info' => 'โ„น๏ธ', + 'success' => 'โœ…', + 'warn' => 'โš ๏ธ', + 'error' => '๐Ÿ”ด', + ]; + /** * Generate a visual diff between two text strings * @@ -135,19 +170,18 @@ private function renderIntelligentDiffHtml(array $operations, array $oldLines, a } // Handle intelligent word-level diffing for modified lines - return $this->enhanceWithWordLevelDiff('', $operations, $oldLines, $newLines); + return $this->enhanceWithWordLevelDiff($operations, $oldLines, $newLines); } /** * Enhance diff with word-level granularity for similar lines * - * @param string $html Current HTML diff * @param array $operations * @param array $oldLines * @param array $newLines * @return string */ - private function enhanceWithWordLevelDiff(string $html, array $operations, array $oldLines, array $newLines): string { + private function enhanceWithWordLevelDiff(array $operations, array $oldLines, array $newLines): string { // Find remove/add pairs that might be line modifications // First pass: collect all removes and adds $removes = []; @@ -209,38 +243,39 @@ private function enhanceWithWordLevelDiff(string $html, array $operations, array // Third pass: detect modifications from remaining removes/adds $usedAdds = $moveDetectedAdds; // Start with adds already used in moves - + $usedRemoves = $moveDetectedRemoves; // Start with removes already used in moves + // Process remaining removes and try to find matching adds for modifications foreach ($removes as $removeIndex => $removeOp) { // Skip removes already used in moves - if (in_array($removeIndex, $moveDetectedRemoves)) { + if (in_array($removeIndex, $usedRemoves)) { continue; } - + $bestMatch = null; $bestScore = -1; $bestAddIndex = -1; - + $oldLine = $oldLines[$removeOp['old_line']] ?? ''; $oldLineNum = $removeOp['old_line'] + 1; - + // Look for best matching add operation foreach ($adds as $addIndex => $addOp) { if (in_array($addIndex, $usedAdds)) { continue; } - + $newLine = $newLines[$addOp['new_line']] ?? ''; $newLineNum = $addOp['new_line'] + 1; - + // Calculate matching score $score = 0; - + // Same line number gets highest priority if ($oldLineNum === $newLineNum) { $score += 100; } - + // Similar content gets secondary priority if ($this->shouldUseWordLevelDiff($oldLine, $newLine)) { $maxLen = max(strlen($oldLine), strlen($newLine)); @@ -248,18 +283,18 @@ private function enhanceWithWordLevelDiff(string $html, array $operations, array $similarity = 1 - ($distance / $maxLen); $score += $similarity * 50; // Up to 50 points for similarity } - + // Proximity bonus (closer line numbers get bonus) $proximityBonus = max(0, 10 - abs($oldLineNum - $newLineNum)); $score += $proximityBonus; - + if ($score > $bestScore) { $bestScore = $score; $bestMatch = $addOp; $bestAddIndex = $addIndex; } } - + // If we found a good match, create a modify operation if ($bestMatch && $bestScore > 10) { // Minimum threshold $enhancedOps[] = [ @@ -268,9 +303,11 @@ private function enhanceWithWordLevelDiff(string $html, array $operations, array 'new_line' => $bestMatch['new_line'] ]; $usedAdds[] = $bestAddIndex; + $usedRemoves[] = $removeIndex; } else { // No good match, keep as remove $enhancedOps[] = $removeOp; + $usedRemoves[] = $removeIndex; } } @@ -284,15 +321,7 @@ private function enhanceWithWordLevelDiff(string $html, array $operations, array // Add remaining unused remove operations (not involved in moves or modifications) foreach ($removes as $removeIndex => $removeOp) { - // Skip removes already used in moves or modifications - $alreadyUsed = false; - foreach ($enhancedOps as $op) { - if (($op['type'] === 'modify' || $op['type'] === 'move') && $op['old_line'] === $removeOp['old_line']) { - $alreadyUsed = true; - break; - } - } - if (!$alreadyUsed) { + if (!in_array($removeIndex, $usedRemoves)) { $enhancedOps[] = $removeOp; } } @@ -303,24 +332,32 @@ private function enhanceWithWordLevelDiff(string $html, array $operations, array } // Now rebuild HTML with only changed lines and line number prefixes + // Format each operation for display, using actual line positions in NEW text $lines = []; - + foreach ($enhancedOps as $operation) { switch ($operation['type']) { case 'add': $line = $newLines[$operation['new_line']] ?? ''; - $lineNumber = $operation['new_line'] + 1; // 1-based line numbers + $newLineNumber = $operation['new_line'] + 1; // 1-based line numbers // Skip empty line additions if (!empty(trim($line))) { - $lines[] = 'โž•' . $lineNumber . ' ' . htmlspecialchars($line, ENT_QUOTES, 'UTF-8') . ''; + $formatted = $this->formatSpecialLine($line); + if ($formatted !== null) { + $lines[] = 'โœจ' . $newLineNumber . ' ' . $formatted . ''; + } } break; case 'remove': $line = $oldLines[$operation['old_line']] ?? ''; - $lineNumber = $operation['old_line'] + 1; // 1-based line numbers + $oldLineNumber = $operation['old_line'] + 1; // 1-based line numbers // Skip empty line removals if (!empty(trim($line))) { - $lines[] = '๐Ÿ—‘๏ธ' . $lineNumber . ' ' . htmlspecialchars($line, ENT_QUOTES, 'UTF-8') . ''; + $formatted = $this->formatSpecialLine($line); + if ($formatted !== null) { + // Show old line number with strikethrough to indicate it's from old version + $lines[] = '๐Ÿ—‘๏ธ' . $oldLineNumber . ' ' . $formatted . ''; + } } break; case 'keep': @@ -329,25 +366,25 @@ private function enhanceWithWordLevelDiff(string $html, array $operations, array case 'modify': $oldLine = $oldLines[$operation['old_line']] ?? ''; $newLine = $newLines[$operation['new_line']] ?? ''; - $lineNumber = $operation['old_line'] + 1; // Use old line number as reference - $lines[] = 'โœ๏ธ' . $lineNumber . ' ' . $this->generateWordLevelDiff($oldLine, $newLine); + $newLineNumber = $operation['new_line'] + 1; // 1-based line numbers + $lines[] = 'โœ๏ธ' . $newLineNumber . ' ' . $this->generateWordLevelDiff($oldLine, $newLine); break; case 'move': $oldLineNum = $operation['old_line'] + 1; $newLineNum = $operation['new_line'] + 1; $content = htmlspecialchars($operation['content'], ENT_QUOTES, 'UTF-8'); - $lines[] = '๐Ÿšš' . $newLineNum . ' ' . $content . ' (moved from line ' . $oldLineNum . ')'; + $lines[] = '๐Ÿšš' . $newLineNum . ' (from ' . $oldLineNum . ') ' . $content; break; } } - // Join all lines without line breaks for clean inline appearance + // Join all lines for display if (empty($lines)) { return ''; } - - // Concatenate lines with spaces for inline display - return implode(' ', $lines); + + // Concatenate lines with bullet separator for better readability in single line + return implode(' | ', $lines); } /** @@ -404,16 +441,13 @@ private function generateWordLevelDiff(string $oldLine, string $newLine): string * @return bool */ private function isCheckboxChange(string $oldLine, string $newLine): bool { - // Pattern for markdown checkboxes: - [ ] or - [x] or - [X] - $checkboxPattern = '/^(\s*-\s*)\[([ xX])\](.*)/i'; - - preg_match($checkboxPattern, $oldLine, $oldMatches); - preg_match($checkboxPattern, $newLine, $newMatches); - - // Both lines must be checkboxes with same prefix/suffix but different checkbox state + preg_match(self::CHECKBOX_PATTERN, $oldLine, $oldMatches); + preg_match(self::CHECKBOX_PATTERN, $newLine, $newMatches); + + // Both lines must be checkboxes with different states + // We only require same prefix and different state - suffix can change return !empty($oldMatches) && !empty($newMatches) && - $oldMatches[1] === $newMatches[1] && // Same prefix - $oldMatches[3] === $newMatches[3] && // Same suffix (text after checkbox) + $oldMatches[1] === $newMatches[1] && // Same prefix (indentation and dash) $oldMatches[2] !== $newMatches[2]; // Different checkbox state } @@ -425,20 +459,61 @@ private function isCheckboxChange(string $oldLine, string $newLine): bool { * @return string */ private function generateCheckboxDiff(string $oldLine, string $newLine): string { - $checkboxPattern = '/^(\s*-\s*)\[([ xX])\](.*)/i'; - - preg_match($checkboxPattern, $oldLine, $oldMatches); - preg_match($checkboxPattern, $newLine, $newMatches); - - $prefix = htmlspecialchars($oldMatches[1], ENT_QUOTES, 'UTF-8'); - $suffix = htmlspecialchars($oldMatches[3], ENT_QUOTES, 'UTF-8'); - - // Convert checkbox states to emoji symbols - $oldCheckbox = (trim(strtolower($oldMatches[2])) === 'x') ? 'โ˜‘' : 'โ˜'; - $newCheckbox = (trim(strtolower($newMatches[2])) === 'x') ? 'โ˜‘' : 'โ˜'; - + preg_match(self::CHECKBOX_PATTERN, $oldLine, $oldMatches); + preg_match(self::CHECKBOX_PATTERN, $newLine, $newMatches); + + $prefix = $oldMatches[1]; + $oldSuffix = $oldMatches[3]; + $newSuffix = $newMatches[3]; + + // Convert checkbox states to checkbox symbols + $oldCheckbox = (trim(strtolower($oldMatches[2])) === 'x') ? 'โ˜‘๏ธ' : '๐Ÿ”ฒ'; + $newCheckbox = (trim(strtolower($newMatches[2])) === 'x') ? 'โ˜‘๏ธ' : '๐Ÿ”ฒ'; + + // If suffix changed too, show that as well + if ($oldSuffix !== $newSuffix) { + return $prefix . $oldCheckbox . 'โ†’' . $newCheckbox . ' ' . $this->generateWordLevelDiff($oldSuffix, $newSuffix); + } + // Show clean transition without del/ins tags on the checkboxes themselves - return $prefix . $oldCheckbox . 'โ†’' . $newCheckbox . $suffix; + return $prefix . $oldCheckbox . 'โ†’' . $newCheckbox . $oldSuffix; + } + + /** + * Format special lines (code blocks, callouts, quotes) with emojis + * + * @param string $line + * @return string|null Returns formatted string or null if line should be ignored + */ + private function formatSpecialLine(string $line): ?string { + $trimmed = trim($line); + + // Ignore block ending markers + if (preg_match(self::BLOCK_END_PATTERN, $trimmed)) { + return null; + } + + // Format code block markers + if (preg_match(self::CODE_BLOCK_PATTERN, $trimmed)) { + return 'โ†’๐Ÿ“'; + } + + // Format callout block markers + if (preg_match(self::CALLOUT_BLOCK_PATTERN, $trimmed, $matches)) { + $type = strtolower($matches[1]); + $emoji = self::CALLOUT_EMOJIS[$type] ?? 'โ„น๏ธ'; + return 'โ†’' . $emoji; + } + + // Format blockquotes + if (preg_match(self::QUOTE_PATTERN, $trimmed)) { + // Remove the > marker and return the quoted text with emoji + $quotedText = preg_replace(self::QUOTE_PATTERN, '', $line); + return 'โ†’๐Ÿ’ฌ ' . htmlspecialchars($quotedText, ENT_QUOTES, 'UTF-8'); + } + + // Return original line if not a special pattern + return htmlspecialchars($line, ENT_QUOTES, 'UTF-8'); } /** From d27a38dd2020ca6da82dd69c836d333961f8f8bb Mon Sep 17 00:00:00 2001 From: Alexander Askin Date: Sun, 5 Oct 2025 10:53:58 +0200 Subject: [PATCH 4/5] fix: improve activity feed compatibility for visual diffs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove HTML tags (, ) from diff output for activity feed compatibility - Add callout block emoji transitions (e.g., โ„น๏ธโ†’๐Ÿ”ด for ::: info โ†’ ::: error) - Use plain text with arrows (โ†’) to show changes instead of HTML formatting The activity feed doesn't support HTML tags properly, causing them to appear as escaped text. This change uses plain text formatting that displays correctly. Signed-off-by: Alexander Askin --- lib/Service/DiffService.php | 58 ++++++++++++++++++++++++++++++------- 1 file changed, 47 insertions(+), 11 deletions(-) diff --git a/lib/Service/DiffService.php b/lib/Service/DiffService.php index b933036bd9..1243f03ca9 100644 --- a/lib/Service/DiffService.php +++ b/lib/Service/DiffService.php @@ -344,7 +344,7 @@ private function enhanceWithWordLevelDiff(array $operations, array $oldLines, ar if (!empty(trim($line))) { $formatted = $this->formatSpecialLine($line); if ($formatted !== null) { - $lines[] = 'โœจ' . $newLineNumber . ' ' . $formatted . ''; + $lines[] = 'โœจ' . $newLineNumber . ' ' . $formatted; } } break; @@ -355,8 +355,7 @@ private function enhanceWithWordLevelDiff(array $operations, array $oldLines, ar if (!empty(trim($line))) { $formatted = $this->formatSpecialLine($line); if ($formatted !== null) { - // Show old line number with strikethrough to indicate it's from old version - $lines[] = '๐Ÿ—‘๏ธ' . $oldLineNumber . ' ' . $formatted . ''; + $lines[] = '๐Ÿ—‘๏ธ' . $oldLineNumber . ' ' . $formatted; } } break; @@ -421,14 +420,19 @@ private function generateWordLevelDiff(string $oldLine, string $newLine): string if ($this->isCheckboxChange($oldLine, $newLine)) { return $this->generateCheckboxDiff($oldLine, $newLine); } - + + // Check if this is a callout block type change + if ($this->isCalloutBlockChange($oldLine, $newLine)) { + return $this->generateCalloutBlockDiff($oldLine, $newLine); + } + // Split lines into words for comparison $oldWords = $this->splitIntoWords($oldLine); $newWords = $this->splitIntoWords($newLine); - + // Get word-level diff operations $wordOps = $this->calculateDiff($oldWords, $newWords); - + // Render word-level diff return $this->renderWordLevelHtml($wordOps, $oldWords, $newWords); } @@ -479,6 +483,38 @@ private function generateCheckboxDiff(string $oldLine, string $newLine): string return $prefix . $oldCheckbox . 'โ†’' . $newCheckbox . $oldSuffix; } + /** + * Check if this is a callout block type change + * + * @param string $oldLine + * @param string $newLine + * @return bool + */ + private function isCalloutBlockChange(string $oldLine, string $newLine): bool { + return preg_match(self::CALLOUT_BLOCK_PATTERN, trim($oldLine)) && + preg_match(self::CALLOUT_BLOCK_PATTERN, trim($newLine)); + } + + /** + * Generate diff for callout block type changes + * + * @param string $oldLine + * @param string $newLine + * @return string + */ + private function generateCalloutBlockDiff(string $oldLine, string $newLine): string { + preg_match(self::CALLOUT_BLOCK_PATTERN, trim($oldLine), $oldMatches); + preg_match(self::CALLOUT_BLOCK_PATTERN, trim($newLine), $newMatches); + + $oldType = strtolower($oldMatches[1]); + $newType = strtolower($newMatches[1]); + + $oldEmoji = self::CALLOUT_EMOJIS[$oldType] ?? 'โ„น๏ธ'; + $newEmoji = self::CALLOUT_EMOJIS[$newType] ?? 'โ„น๏ธ'; + + return $oldEmoji . 'โ†’' . $newEmoji; + } + /** * Format special lines (code blocks, callouts, quotes) with emojis * @@ -552,17 +588,17 @@ private function renderWordLevelHtml(array $operations, array $oldWords, array $ switch ($operation['type']) { case 'add': $word = $newWords[$operation['new_line']] ?? ''; - // Add arrow if previous operation was a deletion + // Add arrow if previous operation was a deletion (showing replacement) if ($lastWasDel) { - $html .= 'โ†’' . htmlspecialchars($word, ENT_QUOTES, 'UTF-8') . ''; + $html .= 'โ†’' . htmlspecialchars($word, ENT_QUOTES, 'UTF-8'); } else { - $html .= '' . htmlspecialchars($word, ENT_QUOTES, 'UTF-8') . ''; + $html .= htmlspecialchars($word, ENT_QUOTES, 'UTF-8'); } $lastWasDel = false; break; case 'remove': $word = $oldWords[$operation['old_line']] ?? ''; - $html .= '' . htmlspecialchars($word, ENT_QUOTES, 'UTF-8') . ''; + $html .= htmlspecialchars($word, ENT_QUOTES, 'UTF-8'); $lastWasDel = true; break; case 'keep': @@ -572,7 +608,7 @@ private function renderWordLevelHtml(array $operations, array $oldWords, array $ break; } } - + return $html; } } From a03dfdc7da2c66e85a50a43947a2ffcc1641aaee Mon Sep 17 00:00:00 2001 From: Alexander Askin Date: Sun, 5 Oct 2025 16:25:50 +0200 Subject: [PATCH 5/5] fix: improve visual diff rendering for activity feed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add quote line detection and formatting for MODIFY operations to prevent > display issues - Combine consecutive and tags to reduce visual clutter - Merge whitespace-only keep operations between add/remove tags for cleaner output - Update emoji symbols for better visual representation: - Error callout: ๐Ÿ”ด โ†’ โ— (exclamation mark) - Quote blocks: ๐Ÿ’ฌ โ†’ โž (quotation mark) - Code blocks: ๐Ÿ“ โ†’ โ€นโ€บ (guillemets) Signed-off-by: Alexander Askin --- lib/Service/DiffService.php | 104 ++++++++++++++++++++++++++++++------ 1 file changed, 87 insertions(+), 17 deletions(-) diff --git a/lib/Service/DiffService.php b/lib/Service/DiffService.php index 1243f03ca9..ad354de7e4 100644 --- a/lib/Service/DiffService.php +++ b/lib/Service/DiffService.php @@ -46,7 +46,7 @@ class DiffService { 'info' => 'โ„น๏ธ', 'success' => 'โœ…', 'warn' => 'โš ๏ธ', - 'error' => '๐Ÿ”ด', + 'error' => 'โ—', ]; /** @@ -426,6 +426,11 @@ private function generateWordLevelDiff(string $oldLine, string $newLine): string return $this->generateCalloutBlockDiff($oldLine, $newLine); } + // Check if either line is a quote line + if ($this->isQuoteLine($oldLine) || $this->isQuoteLine($newLine)) { + return $this->generateQuoteDiff($oldLine, $newLine); + } + // Split lines into words for comparison $oldWords = $this->splitIntoWords($oldLine); $newWords = $this->splitIntoWords($newLine); @@ -515,6 +520,42 @@ private function generateCalloutBlockDiff(string $oldLine, string $newLine): str return $oldEmoji . 'โ†’' . $newEmoji; } + /** + * Check if a line is a quote line + * + * @param string $line + * @return bool + */ + private function isQuoteLine(string $line): bool { + return preg_match(self::QUOTE_PATTERN, trim($line)) === 1; + } + + /** + * Generate diff for quote line changes + * + * @param string $oldLine + * @param string $newLine + * @return string + */ + private function generateQuoteDiff(string $oldLine, string $newLine): string { + $oldText = preg_replace(self::QUOTE_PATTERN, '', $oldLine); + $newText = preg_replace(self::QUOTE_PATTERN, '', $newLine); + + // If both are quotes, show the text change + if ($this->isQuoteLine($oldLine) && $this->isQuoteLine($newLine)) { + return 'โ†’โž ' . htmlspecialchars(trim($oldText), ENT_QUOTES, 'UTF-8') . + 'โ†’' . htmlspecialchars(trim($newText), ENT_QUOTES, 'UTF-8'); + } + + // If only new line is a quote + if ($this->isQuoteLine($newLine)) { + return 'โ†’โž ' . htmlspecialchars(trim($newText), ENT_QUOTES, 'UTF-8'); + } + + // If only old line was a quote + return 'โžโ†’ ' . htmlspecialchars(trim($newText), ENT_QUOTES, 'UTF-8'); + } + /** * Format special lines (code blocks, callouts, quotes) with emojis * @@ -531,7 +572,7 @@ private function formatSpecialLine(string $line): ?string { // Format code block markers if (preg_match(self::CODE_BLOCK_PATTERN, $trimmed)) { - return 'โ†’๐Ÿ“'; + return 'โ†’โ€นโ€บ'; } // Format callout block markers @@ -545,7 +586,7 @@ private function formatSpecialLine(string $line): ?string { if (preg_match(self::QUOTE_PATTERN, $trimmed)) { // Remove the > marker and return the quoted text with emoji $quotedText = preg_replace(self::QUOTE_PATTERN, '', $line); - return 'โ†’๐Ÿ’ฌ ' . htmlspecialchars($quotedText, ENT_QUOTES, 'UTF-8'); + return 'โ†’โž ' . htmlspecialchars($quotedText, ENT_QUOTES, 'UTF-8'); } // Return original line if not a special pattern @@ -582,31 +623,60 @@ private function splitIntoWords(string $text): array { */ private function renderWordLevelHtml(array $operations, array $oldWords, array $newWords): string { $html = ''; - $lastWasDel = false; - + $buffer = ''; + $lastType = null; + foreach ($operations as $operation) { - switch ($operation['type']) { + $currentType = $operation['type']; + $word = ''; + + // Get the word for this operation + switch ($currentType) { case 'add': $word = $newWords[$operation['new_line']] ?? ''; - // Add arrow if previous operation was a deletion (showing replacement) - if ($lastWasDel) { - $html .= 'โ†’' . htmlspecialchars($word, ENT_QUOTES, 'UTF-8'); - } else { - $html .= htmlspecialchars($word, ENT_QUOTES, 'UTF-8'); - } - $lastWasDel = false; break; case 'remove': $word = $oldWords[$operation['old_line']] ?? ''; - $html .= htmlspecialchars($word, ENT_QUOTES, 'UTF-8'); - $lastWasDel = true; break; case 'keep': $word = $oldWords[$operation['old_line']] ?? ''; - $html .= htmlspecialchars($word, ENT_QUOTES, 'UTF-8'); - $lastWasDel = false; break; } + + // If current operation is 'keep' and it's only whitespace, and we have a buffer of add/remove, + // then add it to the buffer to merge tags + if ($currentType === 'keep' && trim($word) === '' && $lastType !== null && $lastType !== 'keep') { + $buffer .= htmlspecialchars($word, ENT_QUOTES, 'UTF-8'); + // Don't change lastType, so we continue with the same operation type + continue; + } + + // If type changed (and not a whitespace-only keep), flush the buffer with appropriate tags + if ($lastType !== null && $lastType !== $currentType && $buffer !== '') { + if ($lastType === 'add') { + $html .= '' . $buffer . ''; + } elseif ($lastType === 'remove') { + $html .= '' . $buffer . ''; + } else { + $html .= $buffer; + } + $buffer = ''; + } + + // Add current word to buffer + $buffer .= htmlspecialchars($word, ENT_QUOTES, 'UTF-8'); + $lastType = $currentType; + } + + // Flush remaining buffer + if ($buffer !== '') { + if ($lastType === 'add') { + $html .= '' . $buffer . ''; + } elseif ($lastType === 'remove') { + $html .= '' . $buffer . ''; + } else { + $html .= $buffer; + } } return $html;