Add compare function between snipsContents

This commit is contained in:
Tim 2025-04-23 21:27:47 +02:00
parent 28a2706525
commit cc3e050304
6 changed files with 179 additions and 22 deletions

View File

@ -0,0 +1,41 @@
<?php
namespace App\Controller;
use App\Entity\SnipContent;
use App\Security\Voter\SnipVoter;
use App\Service\SnipContent\MyersDiff;
use App\Service\SnipContent\SnipContentService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/content', name: 'content')]
class SnipContentController extends AbstractController
{
public function __construct(
private readonly SnipContentService $contentService,
) {}
#[Route('/compare/{to}/{from}', name: '_compare')]
public function compare(SnipContent $to, ?SnipContent $from = null): Response
{
$this->denyAccessUnlessGranted(SnipVoter::VIEW, $to->getSnip());
if ($from === null) {
$from = $to->getParent();
}
$diff = MyersDiff::buildDiffLines(
$this->contentService->rebuildText($from),
$this->contentService->rebuildText($to),
);
dump($diff);
return $this->render('content/compare.html.twig', [
'snip' => $to->getSnip(),
'diff' => $diff,
]);
}
}

View File

@ -0,0 +1,15 @@
<?php
namespace App\Service\SnipContent;
enum DiffTypeEnum: string
{
case INSERT = 'I';
case DELETE = 'D';
case KEEP = 'K';
public function is(string $diffType): bool
{
return $this->value === $diffType;
}
}

View File

@ -52,7 +52,7 @@ class MyersDiff
$x++; $x++;
$count++; $count++;
} }
$solution[] = ['D', $count]; $solution[] = [DiffTypeEnum::DELETE->value, $count];
} }
// Insertions // Insertions
@ -63,10 +63,10 @@ class MyersDiff
$y++; $y++;
} }
$solutionKey = count($solution) - 1; $solutionKey = count($solution) - 1;
if ($solutionKey >= 0 && $solution[$solutionKey][0] === 'I') { if ($solutionKey >= 0 && DiffTypeEnum::INSERT->is($solution[$solutionKey][0])) {
$solution[$solutionKey][1] = array_merge($solution[$solutionKey][1], $values); $solution[$solutionKey][1] = array_merge($solution[$solutionKey][1], $values);
} else { } else {
$solution[] = ['I', $values]; $solution[] = [DiffTypeEnum::INSERT->value, $values];
} }
} }
@ -78,7 +78,7 @@ class MyersDiff
$count++; $count++;
} }
if ($count > 0) { if ($count > 0) {
$solution[] = ['K', $count]; $solution[] = [DiffTypeEnum::KEEP->value, $count];
} }
} }
@ -88,16 +88,24 @@ class MyersDiff
/** /**
* Calculate the shortest edit sequence to convert $x into $y. * Calculate the shortest edit sequence to convert $x into $y.
* *
* @param string $textFrom - tokens (characters, words or lines) * @param string|array $textFrom - tokens (characters, words or lines)
* @param string $textTo - tokens (characters, words or lines) * @param string|array $textTo - tokens (characters, words or lines)
* @param ?callable $compare - comparison function for tokens. Signature is compare($x, $y):bool. If null, === is used. * @param ?callable $compare - comparison function for tokens. Signature is compare($x, $y):bool. If null, === is used.
* *
* @return array[] - pairs of token and edit (-1 for delete, 0 for keep, +1 for insert) * @return array[] - pairs of token and edit (-1 for delete, 0 for keep, +1 for insert)
*/ */
public static function calculate(string $textFrom, string $textTo, ?callable $compare = null): array public static function calculate(string|array $textFrom, string|array $textTo, ?callable $compare = null): array
{ {
$a = self::explode($textFrom); if (is_string($textFrom)) {
$b = self::explode($textTo); $a = self::explode($textFrom);
} else {
$a = $textFrom;
}
if (is_string($textTo)) {
$b = self::explode($textTo);
} else {
$b = $textTo;
}
if ($compare === null) { if ($compare === null) {
$compare = function ($x, $y) { $compare = function ($x, $y) {
@ -144,22 +152,75 @@ class MyersDiff
$x = 0; $x = 0;
foreach ($diff as [$op, $data]) { foreach ($diff as [$op, $data]) {
if ($op === 'K') { switch ($op) {
for ($i = 0; $i < $data; $i++) { case DiffTypeEnum::KEEP->value:
$b[] = $a[$x++]; for ($i = 0; $i < $data; $i++) {
} $b[] = $a[$x++];
} elseif ($op === 'D') { }
$x += $data; // skip deleted break;
} elseif ($op === 'I') { case DiffTypeEnum::DELETE->value:
foreach ($data as $v) { $x += $data; // skip deleted
$b[] = $v; break;
} case DiffTypeEnum::INSERT->value:
foreach ($data as $v) {
$b[] = $v;
}
break;
default:
throw new \InvalidArgumentException('Invalid diff operation');
} }
} }
return self::implode($b); return self::implode($b);
} }
public static function buildDiffLines(string $textFrom, string $textTo): array
{
$a = self::explode($textFrom);
$b = self::explode($textTo);
$diff = MyersDiff::calculate($a, $b);
$lines = [];
$x = 0;
foreach ($diff as [$op, $data]) {
switch ($op) {
case DiffTypeEnum::KEEP->value:
for ($i = 0; $i < $data; $i++) {
$lines[] = [
'type' => 'keep',
'from' => $a[$x],
'to' => $a[$x],
];
$x++;
}
break;
case DiffTypeEnum::DELETE->value:
for ($i = 0; $i < $data; $i++) {
$lines[] = [
'type' => 'delete',
'from' => $a[$x],
'to' => '',
];
$x++;
}
break;
case DiffTypeEnum::INSERT->value:
foreach ($data as $v) {
$lines[] = [
'type' => 'insert',
'from' => '',
'to' => $v,
];
}
break;
default:
throw new \InvalidArgumentException('Invalid diff operation');
}
}
return $lines;
}
private static function explode(string $text): array private static function explode(string $text): array
{ {
return explode(self::NEWLINE, $text); return explode(self::NEWLINE, $text);

View File

@ -41,8 +41,7 @@ readonly class SnipContentService
public function getActiveText(Snip $snip): string public function getActiveText(Snip $snip): string
{ {
$contentRepo = $this->em->getRepository(SnipContent::class); return $this->rebuildText($snip->getActiveVersion());
return $this->rebuildText($contentRepo->find($snip->getActiveVersion()));
} }
public function rebuildText(?SnipContent $snipContent): string public function rebuildText(?SnipContent $snipContent): string

View File

@ -0,0 +1,38 @@
{% extends 'base/single.column.html.twig' %}
{% set title = 'Snip compare ' ~ snip %}
{% block body %}
<a href="{{ path('snip_single', {snip: snip.id}) }}" class="btn btn-primary">
<i class="fa fa-arrow-left"></i> Back
</a>
<br><br>
<table class="table table-condensed table-hover">
<thead>
<tr>
<th>Line</th>
<th>Old</th>
<th>New</th>
</tr>
</thead>
<tbody>
{% for line in diff %}
<tr>
<td class="table-{{ line.type == 'insert' ? 'success' : (line.type == 'delete' ? 'danger' : 'info') }}">
{{ line.type }}
</td>
<td>
{% if line.from %}
{{ line.from }}
{% endif %}
</td>
<td>
{% if line.to %}
{{ line.to }}
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}

View File

@ -26,6 +26,9 @@
<a href="{{ path('snip_raw', {snip: snip.id}) }}" class="btn btn-danger"> <a href="{{ path('snip_raw', {snip: snip.id}) }}" class="btn btn-danger">
<i class="fa fa-eye"></i> Raw <i class="fa fa-eye"></i> Raw
</a> </a>
<a href="{{ path('content_compare', {to: snip.activeVersion.id}) }}" class="btn btn-warning">
<i class="fa fa-eye"></i> Compare
</a>
<br><br> <br><br>
<div class="card" style="width: 100%;"> <div class="card" style="width: 100%;">
<h4 class="card-header"> <h4 class="card-header">