diff --git a/deploy.php b/deploy.php index 901dda4..841f4b3 100644 --- a/deploy.php +++ b/deploy.php @@ -3,6 +3,7 @@ namespace Deployer; require_once 'recipe/common.php'; +require_once 'deploy/git.php'; // Project name set('application', 'snips'); @@ -13,82 +14,48 @@ set('repository', 'git@git.loken.nl:ardent/Snips.git'); // [Optional] Allocate tty for git clone. Default value is false. set('git_tty', true); -// Shared files/dirs between deploys +// Shared files/dirs between deploys set('shared_dirs', ['var/log', 'var/sessions']); set('shared_files', ['.env.local']); //set('writable_dirs', ['var']); + set('migrations_config', ''); set('allow_anonymous_stats', false); +set('console_options', fn() => '--no-interaction'); +set('bin/console', fn() => parse('{{release_path}}/bin/console')); + // Hosts host('snips.loken.nl') ->setRemoteUser('www-data') ->set('branch', function () { return input()->getOption('branch') ?: 'master'; }) - ->set('deploy_path', '~/snips.loken.nl'); - -set('bin/console', function () { - return parse('{{release_path}}/bin/console'); -}); - -set('console_options', function () { - return '--no-interaction'; -}); + ->set('deploy_path', '~/snips.loken.nl') +; desc('Clear cache'); -task('cache:clear', function () { - run('{{bin/php}} {{bin/console}} cache:clear {{console_options}} --no-warmup'); -}); +task('cache:clear', fn() => run('{{bin/php}} {{bin/console}} cache:clear {{console_options}} --no-warmup')); desc('Warm up cache'); -task('cache:warmup', function () { - run('{{bin/php}} {{bin/console}} cache:warmup {{console_options}}'); -}); +task('cache:warmup', fn() => run('{{bin/php}} {{bin/console}} cache:warmup {{console_options}}')); desc('Migrate database'); task('database:migrate', function () { -// $options = '--allow-no-migration'; -// if (get('migrations_config') !== '') { -// $options = sprintf('%s --configuration={{release_path}}/{{migrations_config}}', $options); -// } -// -// run(sprintf('{{bin/php}} {{bin/console}} doctrine:migrations:migrate %s {{console_options}}', $options)); - run('{{bin/php}} {{bin/console}} doctrine:schema:update --force'); + $options = '--allow-no-migration'; + if (get('migrations_config') !== '') { + $options = sprintf('%s --configuration={{release_path}}/{{migrations_config}}', $options); + } + + run(sprintf('{{bin/php}} {{bin/console}} doctrine:migrations:migrate %s {{console_options}}', $options)); }); -task('deployment:log', function () { //https://stackoverflow.com/questions/59686270/how-to-log-deployments-in-deployer - $branch = parse('{{branch}}'); - $date = date('Y-m-d H:i:s'); - $commitHashShort = runLocally('git rev-parse --short HEAD'); -// $commitHash = runLocally('git rev-parse HEAD'); - $commit = explode(PHP_EOL, runLocally('git log -1 --pretty="%H%n%ci"')); - $commitHash = $commit[0]; - $commitDate = $commit[1]; - -// $line = sprintf('%s %s branch="%s" hash="%s"', $date, $commitHashShort, $branch, $commitHash); - $projectUrlBase = 'https://git.loken.nl/ardent/Snips'; - $array = [ - 'branch' => $branch, - 'branchUrl' => sprintf('%s/src/branch/%s', $projectUrlBase, $branch), - 'date' => $date, - 'commitHashShort' => $commitHashShort, - 'commitHashLong' => $commitHash, - 'commitDate' => $commitDate, - 'commitUrl' => sprintf('%s/commit/%s', $projectUrlBase, $commitHash), - 'projectUrl' => $projectUrlBase, - ]; - $json = json_encode($array, JSON_PRETTY_PRINT); - - runLocally("echo '$json' > release.json"); - upload('release.json', '{{release_path}}/release.json'); +desc('Shows current deployed version'); +task('deploy:current', function () { + $current = run('readlink {{deploy_path}}/current'); + writeln("Current deployed version: $current"); }); -//desc('Deploy project'); -//task('deploy', [ -// 'deployment:log', -//]); - desc('Deploy project'); task('deploy', [ 'deploy:prepare', @@ -100,9 +67,9 @@ task('deploy', [ 'deploy:symlink', 'deploy:unlock', 'deploy:cleanup', + 'deploy:current', ]); after('deploy', 'deploy:success'); -// [Optional] if deploy fails automatically unlock. -after('deploy:failed', 'deploy:unlock'); +after('deploy:failed', 'deploy:unlock'); \ No newline at end of file diff --git a/deploy/git.php b/deploy/git.php new file mode 100644 index 0000000..cb61e8b --- /dev/null +++ b/deploy/git.php @@ -0,0 +1,27 @@ + $branch, + 'date' => $date, + 'commitHashShort' => $commitHashShort, + 'commitHashLong' => $commitHash, + 'commitDate' => $commitDate, + ]; + $json = json_encode($array, JSON_PRETTY_PRINT); + + runLocally("echo '$json' > release.json"); + upload('release.json', '{{release_path}}/release.json'); +}); \ No newline at end of file diff --git a/src/Controller/SnipController.php b/src/Controller/SnipController.php index 9b52e28..bd57515 100644 --- a/src/Controller/SnipController.php +++ b/src/Controller/SnipController.php @@ -7,8 +7,8 @@ use App\Form\ConfirmationType; use App\Form\SnipType; use App\Repository\SnipRepository; use App\Security\Voter\SnipVoter; +use App\Service\SnipContent\SnipContentService; use App\Service\SnipParser\Pipeline; -use App\Service\SnipServiceFactory; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\Form\Extension\Core\Type\SubmitType; use Symfony\Component\HttpFoundation\Request; @@ -20,10 +20,8 @@ class SnipController extends AbstractController { public function __construct( private readonly SnipRepository $repository, - private readonly SnipServiceFactory $snipServiceFactory, - ) - { - } + private readonly SnipContentService $contentService, + ) {} #[Route('/', name: '_index')] public function index(): Response @@ -48,11 +46,9 @@ class SnipController extends AbstractController { $this->denyAccessUnlessGranted(SnipVoter::VIEW, $snip); - $snipService = $this->snipServiceFactory->create($snip); - return $this->render('snip/single.html.twig', [ 'snip' => $snip, - 'content' => $pl->parse($snipService->getActiveText()), + 'content' => $pl->parse($this->contentService->getActiveText($snip)), ]); } @@ -62,7 +58,7 @@ class SnipController extends AbstractController $this->denyAccessUnlessGranted(SnipVoter::VIEW, $snip); $response = new Response( - $pl->clean($this->snipServiceFactory->create($snip)->getActiveText()), + $pl->clean($this->contentService->getActiveText($snip)), Response::HTTP_OK, ['Content-Type' => 'text/html'] ); @@ -70,7 +66,8 @@ class SnipController extends AbstractController ->setVary(['Accept', 'Accept-Encoding']) ->setEtag(md5($response->getContent())) ->setTtl(3600) - ->setClientTtl(300); + ->setClientTtl(300) + ; if (!$request->isNoCache()) { $response->isNotModified($request); @@ -87,13 +84,13 @@ class SnipController extends AbstractController $form = $this->createForm(SnipType::class, $snip); $form->add('Save', SubmitType::class); if ($snip->getId()) { - $form->get('content')->setData($this->snipServiceFactory->create($snip)->getActiveText()); + $form->get('content')->setData($this->contentService->getActiveText($snip)); } $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { $this->repository->save($snip); - $this->snipServiceFactory->create($snip)->update($form->get('content')->getData()); + $this->contentService->update($snip, $form->get('content')->getData()); $this->addFlash('success', sprintf('Snip "%s" saved', $snip)); @@ -113,7 +110,8 @@ class SnipController extends AbstractController { $snip = new Snip(); $snip->setCreatedAtNow() - ->setCreatedBy($this->getUser()); + ->setCreatedBy($this->getUser()) + ; return $this->edit($snip, $request); } @@ -126,7 +124,7 @@ class SnipController extends AbstractController $form = $this->createForm(ConfirmationType::class); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { - $this->snipServiceFactory->create($snip)->delete(); + $this->contentService->delete($snip); $this->repository->remove($snip); $this->addFlash('success', sprintf('Snip "%s" deleted', $snip)); return $this->redirectToRoute('snip_index'); diff --git a/src/Controller/VersionController.php b/src/Controller/VersionController.php index e2ae89c..8aa7d2e 100644 --- a/src/Controller/VersionController.php +++ b/src/Controller/VersionController.php @@ -5,7 +5,7 @@ namespace App\Controller; use App\Entity\Snip; use App\Entity\SnipContent; use App\Security\Voter\SnipVoter; -use App\Service\SnipServiceFactory; +use App\Service\SnipContent\SnipContentService; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; @@ -14,7 +14,7 @@ use Symfony\Component\Routing\Attribute\Route; class VersionController extends AbstractController { public function __construct( - private readonly SnipServiceFactory $snipServiceFactory, + private readonly SnipContentService $contentService, ) {} #[Route('/', name: '_index')] @@ -32,7 +32,7 @@ class VersionController extends AbstractController { $this->denyAccessUnlessGranted(SnipVoter::EDIT, $snip); - $this->snipServiceFactory->create($snip)->setVersion($version); + $this->contentService->setVersion($snip, $version); $this->addFlash('success', 'Snip version set to ' . $version->getId()); return $this->redirectToRoute('snip_single', ['snip' => $snip->getId()]); } diff --git a/src/Entity/SnipContent.php b/src/Entity/SnipContent.php index ee98d9b..babb55e 100644 --- a/src/Entity/SnipContent.php +++ b/src/Entity/SnipContent.php @@ -32,6 +32,9 @@ class SnipContent #[ORM\Column(type: Types::TEXT, nullable: true)] private ?string $text = null; + #[ORM\Column(nullable: true)] + private ?array $diff = null; + public function __construct() { $this->children = new ArrayCollection(); @@ -107,4 +110,16 @@ class SnipContent return $this; } + + public function getDiff(): ?array + { + return $this->diff; + } + + public function setDiff(?array $diff): static + { + $this->diff = $diff; + + return $this; + } } diff --git a/src/Service/SnipContent/MyersDiff.php b/src/Service/SnipContent/MyersDiff.php new file mode 100644 index 0000000..8f55c93 --- /dev/null +++ b/src/Service/SnipContent/MyersDiff.php @@ -0,0 +1,171 @@ += 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); + } +} \ No newline at end of file diff --git a/src/Service/SnipContent/SnipContentService.php b/src/Service/SnipContent/SnipContentService.php index 4291d63..fba5d9b 100644 --- a/src/Service/SnipContent/SnipContentService.php +++ b/src/Service/SnipContent/SnipContentService.php @@ -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 } diff --git a/src/Service/SnipParser/Stages/IncludeReferenceStage.php b/src/Service/SnipParser/Stages/IncludeReferenceStage.php index db887f7..894820a 100644 --- a/src/Service/SnipParser/Stages/IncludeReferenceStage.php +++ b/src/Service/SnipParser/Stages/IncludeReferenceStage.php @@ -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('%s', $matches[0]); } - return $this->pipeline->parse($content->getText()); + return $this->pipeline->parse( + $this->snipContentService->rebuildText($content) + ); }, $payload); } } \ No newline at end of file