diff --git a/lib/Activity/DeckProvider.php b/lib/Activity/DeckProvider.php index f18842f48..475b3f684 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,8 +38,10 @@ 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) { + 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; @@ -47,6 +50,7 @@ public function __construct(IURLGenerator $urlGenerator, ActivityManager $activi $this->l10nFactory = $l10n; $this->config = $config; $this->cardService = $cardService; + $this->diffService = $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/AppInfo/Application.php b/lib/AppInfo/Application.php index 5b0c7bb36..1a90cc8dd 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 new file mode 100644 index 000000000..ad354de7e --- /dev/null +++ b/lib/Service/DiffService.php @@ -0,0 +1,684 @@ + 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 + * + * @param string $oldText The original text + * @param string $newText The new text + * @return string HTML representation of the diff + */ + public function generateDiff(string $oldText, string $newText): string { + // Convert texts to arrays of lines for comparison + $oldLines = $this->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 array $operations + * @param array $oldLines + * @param array $newLines + * @return 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 = []; + $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 + $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, $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)); + $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; + $usedRemoves[] = $removeIndex; + } else { + // No good match, keep as remove + $enhancedOps[] = $removeOp; + $usedRemoves[] = $removeIndex; + } + } + + // 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) { + if (!in_array($removeIndex, $usedRemoves)) { + $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 + // 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']] ?? ''; + $newLineNumber = $operation['new_line'] + 1; // 1-based line numbers + // Skip empty line additions + if (!empty(trim($line))) { + $formatted = $this->formatSpecialLine($line); + if ($formatted !== null) { + $lines[] = '✨' . $newLineNumber . ' ' . $formatted; + } + } + break; + case 'remove': + $line = $oldLines[$operation['old_line']] ?? ''; + $oldLineNumber = $operation['old_line'] + 1; // 1-based line numbers + // Skip empty line removals + if (!empty(trim($line))) { + $formatted = $this->formatSpecialLine($line); + if ($formatted !== null) { + $lines[] = '🗑️' . $oldLineNumber . ' ' . $formatted; + } + } + 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']] ?? ''; + $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 . ' (from ' . $oldLineNum . ') ' . $content; + break; + } + } + + // Join all lines for display + if (empty($lines)) { + return ''; + } + + // Concatenate lines with bullet separator for better readability in single line + 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); + } + + // Check if this is a callout block type change + if ($this->isCalloutBlockChange($oldLine, $newLine)) { + 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); + + // 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 { + 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 (indentation and dash) + $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 { + 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 . $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; + } + + /** + * 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 + * + * @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'); + } + + /** + * 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 = ''; + $buffer = ''; + $lastType = null; + + foreach ($operations as $operation) { + $currentType = $operation['type']; + $word = ''; + + // Get the word for this operation + switch ($currentType) { + case 'add': + $word = $newWords[$operation['new_line']] ?? ''; + break; + case 'remove': + $word = $oldWords[$operation['old_line']] ?? ''; + break; + case 'keep': + $word = $oldWords[$operation['old_line']] ?? ''; + 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; + } +} diff --git a/tests/unit/Activity/DeckProviderTest.php b/tests/unit/Activity/DeckProviderTest.php index c82520087..263b33fc0 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 ABCBCD', $event->getParsedMessage()); } public function testParseParamForBoard() {