Create a customized myers diff based system for snip content

This commit is contained in:
Tim
2025-04-14 23:25:11 +02:00
parent a18eda6748
commit 2db9c5f1d9
8 changed files with 317 additions and 88 deletions

View File

@ -0,0 +1,171 @@
<?php
namespace App\Service\SnipContent;
class MyersDiff
{
private const string NEWLINE = "\r\n";
/**
* Backtrack through the intermediate results to extract the "snakes" that
* are visited on the chosen "D-path".
*
* @param string[] $v_save Intermediate results
* @param int $x End position
* @param int $y End position
*
* @return int[][]
*/
private static function extractSnakes(array $v_save, int $x, int $y): array
{
$snakes = [];
for ($d = count($v_save) - 1; $x >= 0 && $y >= 0; $d--) {
array_unshift($snakes, [$x, $y]);
$v = $v_save[$d];
$k = $x - $y;
if ($k === -$d || $k !== $d && $v[$k - 1] < $v[$k + 1]) {
$k_prev = $k + 1;
} else {
$k_prev = $k - 1;
}
$x = $v[$k_prev];
$y = $x - $k_prev;
}
return $snakes;
}
private static function formatCompact(array $snakes, array $b): array
{
$solution = [];
$x = 0;
$y = 0;
foreach ($snakes as $snake) {
// Deletions
while ($snake[0] - $snake[1] > $x - $y) {
$count = 0;
while ($snake[0] - $snake[1] > $x - $y) {
$x++;
$count++;
}
$solution[] = ['D', $count];
}
// Insertions
while ($snake[0] - $snake[1] < $x - $y) {
$values = [];
while ($snake[0] - $snake[1] < $x - $y) {
$values[] = $b[$y];
$y++;
}
if ($solution[count($solution) - 1][0] === 'I') {
$solution[count($solution) - 1][1] = array_merge($solution[count($solution) - 1][1], $values);
} else {
$solution[] = ['I', $values];
}
}
// Keeps (snake diagonals)
$count = 0;
while ($x < $snake[0]) {
$x++;
$y++;
$count++;
}
if ($count > 0) {
$solution[] = ['K', $count];
}
}
return $solution;
}
/**
* Calculate the shortest edit sequence to convert $x into $y.
*
* @param string $textFrom - tokens (characters, words or lines)
* @param string $textTo - tokens (characters, words or lines)
* @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)
*/
public static function calculate(string $textFrom, string $textTo, ?callable $compare = null): array
{
$a = self::explode($textFrom);
$b = self::explode($textTo);
if ($compare === null) {
$compare = function ($x, $y) {
return $x === $y;
};
}
$n = count($a);
$m = count($b);
$a = array_values($a);
$b = array_values($b);
$max = $m + $n;
$v_save = [];
$v = [1 => 0];
for ($d = 0; $d <= $max; $d++) {
for ($k = -$d; $k <= $d; $k += 2) {
if ($k === -$d || $k !== $d && $v[$k - 1] < $v[$k + 1]) {
$x = $v[$k + 1];
} else {
$x = $v[$k - 1] + 1;
}
$y = $x - $k;
while ($x < $n && $y < $m && $compare($a[$x], $b[$y])) {
$x++;
$y++;
}
$v[$k] = $x;
$v_save[$d] = $v;
if ($x === $n && $y === $m) {
break 2;
}
}
}
return self::formatCompact(self::extractSnakes($v_save, $n, $m), $b);
}
public static function rebuildBFromCompact(string $textFrom, array $diff): string
{
$a = self::explode($textFrom);
$b = [];
$x = 0;
foreach ($diff as [$op, $data]) {
if ($op === 'K') {
for ($i = 0; $i < $data; $i++) {
$b[] = $a[$x++];
}
} elseif ($op === 'D') {
$x += $data; // skip deleted
} elseif ($op === 'I') {
foreach ($data as $v) {
$b[] = $v;
}
}
}
return self::implode($b);
}
private static function explode(string $text): array
{
return explode(self::NEWLINE, $text);
}
private static function implode(array $text): string
{
return implode(self::NEWLINE, $text);
}
}

View File

@ -8,49 +8,96 @@ use Doctrine\ORM\EntityManagerInterface;
readonly class SnipContentService
{
public function __construct(
private Snip $snip,
private EntityManagerInterface $em,
) {}
public function update(string $snipContents): void
public function update(Snip $snip, string $snipContents): void
{
if ($this->snip->getActiveVersion()?->getText() === $snipContents) {
$parentContent = $snip->getActiveVersion();
if ($this->rebuildText($parentContent) === $snipContents) {
return;
}
// Create new snipContent entity with previous one as parent
$content = new SnipContent();
$content
->setText($snipContents)
->setSnip($this->snip)
->setSnip($snip)
;
if ($this->snip->getActiveVersion() !== null) {
$content->setParent($this->snip->getActiveVersion());
if ($parentContent !== null) {
$content->setParent($parentContent);
$this->contentToRelative($parentContent);
}
$this->em->persist($content);
$this->em->flush();
$this->snip->setActiveVersion($content);
$this->em->persist($this->snip);
$snip->setActiveVersion($content);
$this->em->persist($snip);
$this->em->flush();
}
// Shortcut to get the active text
public function getActiveText(): string
public function getActiveText(Snip $snip): string
{
$contentRepo = $this->em->getRepository(SnipContent::class);
return $contentRepo->find($this->snip->getActiveVersion())->getText();
return $this->rebuildText($contentRepo->find($snip->getActiveVersion()));
}
public function setVersion(SnipContent $version): void
public function rebuildText(SnipContent $snipContent): string
{
$this->snip->setActiveVersion($version);
$this->em->persist($this->snip);
if ($snipContent->getText()) {
return $snipContent->getText();
}
$parentContent = $snipContent->getParent();
if ($parentContent === null && $snipContent->getDiff() === null) {
return '---Something went very wrong, cant rebuild the text---';
}
return MyersDiff::rebuildBFromCompact(
$this->rebuildText($parentContent), $snipContent->getDiff()
);
}
public function setVersion(Snip $snip, SnipContent $version): void
{
$activeVersion = $snip->getActiveVersion();
$this->contentToAbsolute($version);
$this->contentToRelative($activeVersion);
$snip->setActiveVersion($version);
$this->em->persist($snip);
$this->em->flush();
}
public function delete(): void
public function contentToRelative(SnipContent $content): void
{
if ($content->getText() === null || $content->getParent() === null) {
return;
}
$contentText = $content->getText();
$parentText = $this->rebuildText($content->getParent());
$diff = MyersDiff::calculate($parentText, $contentText);
$content->setDiff($diff);
$content->setText(null);
$this->em->persist($content);
$this->em->flush();
}
public function contentToAbsolute(SnipContent $content): void
{
if ($content->getDiff() === null) {
return;
}
$content->setText($this->rebuildText($content));
$content->setDiff(null);
$this->em->persist($content);
$this->em->flush();
}
public function delete(Snip $snip): void
{
// Cleanup the versions
}

View File

@ -5,6 +5,7 @@ namespace App\Service\SnipParser\Stages;
use App\Repository\SnipContentRepository;
use App\Repository\SnipRepository;
use App\Security\Voter\SnipVoter;
use App\Service\SnipContent\SnipContentService;
use App\Service\SnipParser\Pipeline;
use League\Pipeline\StageInterface;
use Symfony\Bundle\SecurityBundle\Security;
@ -17,6 +18,7 @@ class IncludeReferenceStage implements StageInterface
#[Autowire(lazy: true)] private readonly SnipRepository $snipRepository,
#[Autowire(lazy: true)] private readonly SnipContentRepository $snipContentRepository,
#[Autowire(lazy: true)] private readonly Pipeline $pipeline,
#[Autowire(lazy: true)] private readonly SnipContentService $snipContentService,
) {}
public function __invoke(mixed $payload): string
@ -51,7 +53,9 @@ class IncludeReferenceStage implements StageInterface
return sprintf('<span title="access denied">%s</span>', $matches[0]);
}
return $this->pipeline->parse($content->getText());
return $this->pipeline->parse(
$this->snipContentService->rebuildText($content)
);
}, $payload);
}
}