2 Commits

Author SHA1 Message Date
Tim
283c9ecb27 Remove dump 2025-05-15 01:15:57 +02:00
Tim
0e5d92258d Implement flowchart renderer for versions 2025-05-14 00:22:25 +02:00
49 changed files with 1253 additions and 1100 deletions

View File

@ -1,17 +0,0 @@
# editorconfig.org
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[{compose.yaml,compose.*.yaml}]
indent_size = 2
[*.md]
trim_trailing_whitespace = false

2
.env
View File

@ -24,6 +24,6 @@ APP_SECRET=
# IMPORTANT: You MUST configure your server version, either here or in config/packages/doctrine.yaml # IMPORTANT: You MUST configure your server version, either here or in config/packages/doctrine.yaml
# #
# DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db" # DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db"
# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=10.11.13-MariaDB&charset=utf8mb4" # DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=mariadb-10.9.5&charset=utf8mb4"
# DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=15&charset=utf8" # DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=15&charset=utf8"
###< doctrine/doctrine-bundle ### ###< doctrine/doctrine-bundle ###

View File

@ -8,20 +8,18 @@
"ext-ctype": "*", "ext-ctype": "*",
"ext-iconv": "*", "ext-iconv": "*",
"doctrine/doctrine-bundle": "^2.9", "doctrine/doctrine-bundle": "^2.9",
"doctrine/doctrine-migrations-bundle": "^3.4", "doctrine/doctrine-migrations-bundle": "^3.2",
"doctrine/orm": "^3.4", "doctrine/orm": "^2.14",
"league/commonmark": "^2.6", "league/commonmark": "^2.6",
"league/pipeline": "^1.0", "league/pipeline": "^1.0",
"phpdocumentor/reflection-docblock": "^5.6", "phpdocumentor/reflection-docblock": "^5.6",
"phpstan/phpdoc-parser": "^2.1", "phpstan/phpdoc-parser": "^2.1",
"symfony/asset": "*",
"symfony/console": "*", "symfony/console": "*",
"symfony/dotenv": "*", "symfony/dotenv": "*",
"symfony/flex": "^2", "symfony/flex": "^2",
"symfony/form": "*", "symfony/form": "*",
"symfony/framework-bundle": "*", "symfony/framework-bundle": "*",
"symfony/monolog-bundle": "^3.0", "symfony/monolog-bundle": "^3.0",
"symfony/object-mapper": "7.3.*",
"symfony/property-access": "*", "symfony/property-access": "*",
"symfony/property-info": "*", "symfony/property-info": "*",
"symfony/runtime": "*", "symfony/runtime": "*",
@ -31,7 +29,6 @@
"symfony/uid": "*", "symfony/uid": "*",
"symfony/validator": "*", "symfony/validator": "*",
"symfony/yaml": "*", "symfony/yaml": "*",
"tempest/highlight": "^2.11",
"twig/extra-bundle": "^2.12|^3.0", "twig/extra-bundle": "^2.12|^3.0",
"twig/twig": "^3.0" "twig/twig": "^3.0"
}, },
@ -85,7 +82,7 @@
"extra": { "extra": {
"symfony": { "symfony": {
"allow-contrib": false, "allow-contrib": false,
"require": "7.3.*" "require": "7.2.*"
} }
} }
} }

1200
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -11,7 +11,7 @@ doctrine:
orm: orm:
report_fields_where_declared: true report_fields_where_declared: true
auto_generate_proxy_classes: true auto_generate_proxy_classes: true
enable_native_lazy_objects: true enable_lazy_ghost_objects: true
validate_xml_mapping: true validate_xml_mapping: true
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
identity_generation_preferences: identity_generation_preferences:

View File

@ -1,3 +0,0 @@
framework:
property_info:
with_constructor_extractor: true

View File

@ -8,6 +8,4 @@ when@dev:
when@test: when@test:
framework: framework:
profiler: profiler: { collect: false }
collect: false
collect_serializer_data: true

View File

@ -1,4 +1,4 @@
when@dev: when@dev:
_errors: _errors:
resource: '@FrameworkBundle/Resources/config/routing/errors.php' resource: '@FrameworkBundle/Resources/config/routing/errors.xml'
prefix: /_error prefix: /_error

View File

@ -1,8 +1,8 @@
when@dev: when@dev:
web_profiler_wdt: web_profiler_wdt:
resource: '@WebProfilerBundle/Resources/config/routing/wdt.php' resource: '@WebProfilerBundle/Resources/config/routing/wdt.xml'
prefix: /_wdt prefix: /_wdt
web_profiler_profiler: web_profiler_profiler:
resource: '@WebProfilerBundle/Resources/config/routing/profiler.php' resource: '@WebProfilerBundle/Resources/config/routing/profiler.xml'
prefix: /_profiler prefix: /_profiler

View File

@ -15,6 +15,7 @@ services:
# this creates a service per class whose id is the fully-qualified class name # this creates a service per class whose id is the fully-qualified class name
App\: App\:
resource: '../src/' resource: '../src/'
exclude:
# add more service definitions when explicit configuration is needed - '../src/DependencyInjection/'
# please note that last definitions always *replace* previous ones - '../src/Entity/'
- '../src/Kernel.php'

View File

@ -1,87 +0,0 @@
pre, code {
color: #1f2328;
background-color: #ffffff;
}
.hl-keyword {
color: #cf222e;
}
.hl-property {
color: #8250df;
}
.hl-attribute {
font-style: italic;
}
.hl-type {
color: #EA4334;
}
.hl-generic {
color: #9d3af6;
}
.hl-value {
color: #0a3069;
}
.hl-literal {
color: #0a3069;
}
.hl-number {
color: #0a3069;
}
.hl-variable {
color: #953800;
}
.hl-comment {
color: #6e7781;
}
.hl-blur {
filter: blur(2px);
}
.hl-strong {
font-weight: bold;
}
.hl-em {
font-style: italic;
}
.hl-addition {
display: inline-block;
min-width: 100%;
background-color: #00FF0022;
}
.hl-deletion {
display: inline-block;
min-width: 100%;
background-color: #FF000011;
}
.hl-gutter {
display: inline-block;
font-size: 0.9em;
color: #555;
padding: 0 1ch;
margin-right: 1ch;
user-select: none;
}
.hl-gutter-addition {
background-color: #34A853;
color: #fff;
}
.hl-gutter-deletion {
background-color: #EA4334;
color: #fff;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

View File

@ -26,16 +26,17 @@ class SnipUpdateContentCommand extends Command
protected function execute(InputInterface $input, OutputInterface $output): int protected function execute(InputInterface $input, OutputInterface $output): int
{ {
// $io = new SymfonyStyle($input, $output); $io = new SymfonyStyle($input, $output);
$qb = $this->snipContentRepository->createQueryBuilder('s'); $qb = $this->snipContentRepository->createQueryBuilder('s');
$qb->where('s.text IS NOT NULL'); $qb->where('s.text IS NOT NULL');
$c = 0;
/** @var SnipContent $snipContent */ /** @var SnipContent $snipContent */
foreach ($qb->getQuery()->getResult() as $snipContent) { foreach ($qb->getQuery()->getResult() as $snipContent) {
$text = $snipContent->text; $text = $snipContent->getText();
$text = Lexer::reconstruct(Lexer::tokenize($text)); $text = Lexer::reconstruct(Lexer::tokenize($text));
$snipContent->text = $text; $snipContent->setText($text);
$this->snipContentRepository->save($snipContent); $this->snipContentRepository->save($snipContent);
} }

View File

@ -10,7 +10,6 @@ use App\Security\Voter\SnipVoter;
use App\Service\SnipContent\SnipContentService; use App\Service\SnipContent\SnipContentService;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload; use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Symfony\Component\ObjectMapper\ObjectMapperInterface;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
class ApiController extends AbstractApiController class ApiController extends AbstractApiController
@ -35,11 +34,11 @@ class ApiController extends AbstractApiController
$this->denyAccessUnlessGranted(SnipVoter::VIEW, $snip); $this->denyAccessUnlessGranted(SnipVoter::VIEW, $snip);
return $this->successResponse([ return $this->successResponse([
'id' => $snip->id, 'id' => $snip->getId(),
'content' => $snip->getActiveText(), 'content' => $snip->getActiveText(),
'createdBy' => [ 'createdBy' => [
'id' => $snip->createdBy->getId(), 'id' => $snip->getCreatedBy()->getId(),
'name' => $snip->createdBy->getName(), 'name' => $snip->getCreatedBy()->getName(),
], ],
]); ]);
} }
@ -50,29 +49,28 @@ class ApiController extends AbstractApiController
#[MapRequestPayload] SnipPostRequest $request, #[MapRequestPayload] SnipPostRequest $request,
SnipContentService $cs, SnipContentService $cs,
SnipRepository $repo, SnipRepository $repo,
ObjectMapperInterface $mapper,
): Response ): Response
{ {
$this->denyAccessUnlessGranted(SnipVoter::EDIT, $snip); $this->denyAccessUnlessGranted(SnipVoter::EDIT, $snip);
if (!($snip->activeVersion === $snip->getLatestVersion())) { if (!($snip->getActiveVersion() === $snip->getLatestVersion())) {
return $this->errorResponse('Snip is not the latest version'); return $this->errorResponse('Snip is not the latest version');
} }
$mapper->map($request, $snip); $request->pushToSnip($snip);
$repo->save($snip); $repo->save($snip);
if ($request->content !== null) { if ($request->content !== null) {
$cs->update($snip, $request->content, $request->contentName); $cs->update($snip, $request->content);
} }
return $this->successResponse([ return $this->successResponse([
'id' => $snip->id, 'id' => $snip->getId(),
'name' => $snip->name, 'name' => $snip->getName(),
'content' => $snip->getActiveText(), 'content' => $snip->getActiveText(),
'createdBy' => [ 'createdBy' => [
'id' => $snip->createdBy->getId(), 'id' => $snip->getCreatedBy()->getId(),
'name' => $snip->createdBy->getName(), 'name' => $snip->getCreatedBy()->getName(),
], ],
]); ]);
} }
} }

View File

@ -17,4 +17,4 @@ class HomeController extends AbstractController
return $this->redirectToRoute('snip_public'); return $this->redirectToRoute('snip_public');
} }
} }
} }

View File

@ -18,10 +18,10 @@ class SnipContentController extends AbstractController
#[Route('/compare/{to}/{from}', name: '_compare')] #[Route('/compare/{to}/{from}', name: '_compare')]
public function compare(SnipContent $to, ?SnipContent $from = null): Response public function compare(SnipContent $to, ?SnipContent $from = null): Response
{ {
$this->denyAccessUnlessGranted(SnipVoter::VIEW, $to->snip); $this->denyAccessUnlessGranted(SnipVoter::VIEW, $to->getSnip());
if ($from === null) { if ($from === null) {
$from = $to->parent; $from = $to->getParent();
} }
$diff = MyersDiff::buildDiffLines( $diff = MyersDiff::buildDiffLines(
@ -30,8 +30,8 @@ class SnipContentController extends AbstractController
); );
return $this->render('content/compare.html.twig', [ return $this->render('content/compare.html.twig', [
'snip' => $to->snip, 'snip' => $to->getSnip(),
'diff' => $diff, 'diff' => $diff,
]); ]);
} }
} }

View File

@ -81,26 +81,14 @@ class SnipController extends AbstractController
{ {
$this->denyAccessUnlessGranted(SnipVoter::EDIT, $snip); $this->denyAccessUnlessGranted(SnipVoter::EDIT, $snip);
/** $form = $this->createForm(SnipType::class, $snip);
* Temporary solution to prevent editing of old versions $form->add('Save', SubmitType::class);
* It technically fully works, but rendering the version history needs an update first if ($snip->getId()) {
*/ $form->get('content')->setData($snip->getActiveText());
$isLatest = $snip->activeVersion === $snip->getLatestVersion();
if (!$isLatest) {
$this->addFlash('error', 'Snip is not the latest version, changes will not be saved.');
} }
$form = $this->createForm(SnipType::class, $snip)
->add('Save', SubmitType::class);
$form->get('content')->setData($snip->getActiveText());
$form->handleRequest($request); $form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) { if ($form->isSubmitted() && $form->isValid()) {
if (!$isLatest) {
return $this->redirectToRoute('snip_single', [
'snip' => $snip->id,
]);
}
$this->repository->save($snip); $this->repository->save($snip);
$contentService->update( $contentService->update(
$snip, $snip,
@ -110,9 +98,7 @@ class SnipController extends AbstractController
$this->addFlash('success', sprintf('Snip "%s" saved', $snip)); $this->addFlash('success', sprintf('Snip "%s" saved', $snip));
return $this->redirectToRoute('snip_single', [ return $this->redirectToRoute('snip_single', ['snip' => $snip->getId()]);
'snip' => $snip->id,
]);
} }
return $this->render('snip/edit.html.twig', [ return $this->render('snip/edit.html.twig', [
@ -125,32 +111,11 @@ class SnipController extends AbstractController
public function new(Request $request, SnipContentService $contentService): Response public function new(Request $request, SnipContentService $contentService): Response
{ {
$snip = new Snip(); $snip = new Snip();
$snip->setCreatedAtNow(); $snip->setCreatedAtNow()
$snip->createdBy = $this->getUser(); ->setCreatedBy($this->getUser())
;
$form = $this->createForm(SnipType::class, $snip); return $this->edit($snip, $request, $contentService);
$form->add('Create', SubmitType::class);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->repository->save($snip);
$contentService->update(
$snip,
$form->get('content')->getData(),
$form->get('contentName')->getData()
);
$this->addFlash('success', sprintf('Snip "%s" created', $snip));
return $this->redirectToRoute('snip_single', [
'snip' => $snip->id,
]);
}
return $this->render('snip/create.html.twig', [
'snip' => $snip,
'form' => $form->createView(),
]);
} }
#[Route('/delete/{snip}', name: '_delete')] #[Route('/delete/{snip}', name: '_delete')]
@ -161,7 +126,7 @@ class SnipController extends AbstractController
$form = $this->createForm(ConfirmationType::class); $form = $this->createForm(ConfirmationType::class);
$form->handleRequest($request); $form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) { if ($form->isSubmitted() && $form->isValid()) {
$snip->activeVersion = null; $snip->setActiveVersion(null);
$this->repository->save($snip); $this->repository->save($snip);
$this->repository->remove($snip); $this->repository->remove($snip);
$this->addFlash('success', sprintf('Snip "%s" deleted', $snip)); $this->addFlash('success', sprintf('Snip "%s" deleted', $snip));
@ -178,14 +143,14 @@ class SnipController extends AbstractController
public function archive(Snip $snip): Response public function archive(Snip $snip): Response
{ {
$this->denyAccessUnlessGranted(SnipVoter::EDIT, $snip); $this->denyAccessUnlessGranted(SnipVoter::EDIT, $snip);
$snip->archived = !$snip->archived; $snip->setArchived(!$snip->isArchived());
$this->repository->save($snip); $this->repository->save($snip);
if ($snip->archived) { if ($snip->isArchived()) {
$this->addFlash('success', sprintf('Snip "%s" archived', $snip)); $this->addFlash('success', sprintf('Snip "%s" archived', $snip));
} else { } else {
$this->addFlash('success', sprintf('Snip "%s" unarchived', $snip)); $this->addFlash('success', sprintf('Snip "%s" unarchived', $snip));
} }
return $this->redirectToRoute('snip_edit', ['snip' => $snip->id]); return $this->redirectToRoute('snip_single', ['snip' => $snip->getId()]);
} }
} }

View File

@ -5,6 +5,7 @@ namespace App\Controller;
use App\Entity\Snip; use App\Entity\Snip;
use App\Entity\SnipContent; use App\Entity\SnipContent;
use App\Security\Voter\SnipVoter; use App\Security\Voter\SnipVoter;
use App\Service\SnipContent\FlowChartTreeBuilder;
use App\Service\SnipContent\SnipContentService; use App\Service\SnipContent\SnipContentService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
@ -18,12 +19,15 @@ class VersionController extends AbstractController
) {} ) {}
#[Route('/', name: '_index')] #[Route('/', name: '_index')]
public function index(Snip $snip): Response public function index(Snip $snip, FlowChartTreeBuilder $builder): Response
{ {
$this->denyAccessUnlessGranted(SnipVoter::EDIT, $snip); $this->denyAccessUnlessGranted(SnipVoter::EDIT, $snip);
$buildTree = $builder->buildTree($snip);
return $this->render('version/index.html.twig', [ return $this->render('version/index.html.twig', [
'snip' => $snip, 'snip' => $snip,
// 'versions' => new GitTreeBuilder($snip)->buildTree($snip->getSnipContents()->first()),
'versions' => $buildTree,
]); ]);
} }
@ -33,7 +37,7 @@ class VersionController extends AbstractController
$this->denyAccessUnlessGranted(SnipVoter::EDIT, $snip); $this->denyAccessUnlessGranted(SnipVoter::EDIT, $snip);
$this->contentService->setVersion($snip, $version); $this->contentService->setVersion($snip, $version);
$this->addFlash('success', 'Snip version set to ' . $version->id); $this->addFlash('success', 'Snip version set to ' . $version->getId());
return $this->redirectToRoute('snip_single', ['snip' => $snip->id]); return $this->redirectToRoute('snip_single', ['snip' => $snip->getId()]);
} }
} }

View File

@ -1,14 +0,0 @@
<?php
namespace App\Dto\Condition;
use Symfony\Component\ObjectMapper\ConditionCallableInterface;
class ConditionNotNull implements ConditionCallableInterface
{
public function __invoke(mixed $value, object $source, ?object $target): bool
{
return null !== $value;
}
}

View File

@ -20,4 +20,13 @@ readonly class SnipFilterRequest implements CachableDtoInterface
public ?string $sort = self::SORT_NAME, public ?string $sort = self::SORT_NAME,
public ?string $tag = self::TAG_ALL, public ?string $tag = self::TAG_ALL,
) {} ) {}
}
public function toArray(): array
{
return [
'visibility' => $this->visibility,
'sort' => $this->sort,
'tag' => $this->tag,
];
}
}

View File

@ -2,21 +2,27 @@
namespace App\Dto; namespace App\Dto;
use App\Dto\Condition\ConditionNotNull;
use App\Entity\Snip; use App\Entity\Snip;
use Symfony\Component\ObjectMapper\Attribute\Map;
#[Map(target: Snip::class)]
class SnipPostRequest class SnipPostRequest
{ {
public function __construct( public function __construct(
#[Map(if: new ConditionNotNull())]
public ?string $name = null, public ?string $name = null,
public ?string $content = null, public ?string $content = null,
#[Map(if: new ConditionNotNull())]
public ?bool $public = null, public ?bool $public = null,
#[Map(if: new ConditionNotNull())]
public ?bool $visible = null, public ?bool $visible = null,
public ?string $contentName = null,
) {} ) {}
}
public function pushToSnip(Snip $snip): void
{
if ($this->name !== null) {
$snip->setName($this->name);
}
if ($this->public !== null) {
$snip->setPublic($this->public);
}
if ($this->visible !== null) {
$snip->setVisible($this->visible);
}
}
}

View File

@ -10,23 +10,47 @@ trait TrackedTrait
{ {
#[ORM\Column] #[ORM\Column]
#[ORM\JoinColumn(nullable: false)] #[ORM\JoinColumn(nullable: false)]
public ?DateTime $createdAt = null; private ?DateTime $createdAt = null;
#[ORM\ManyToOne] #[ORM\ManyToOne]
#[ORM\JoinColumn(nullable: false)] #[ORM\JoinColumn(nullable: false)]
public ?User $createdBy = null; private ?User $createdBy = null;
public function getCreatedBy(): ?User
{
return $this->createdBy;
}
public function setCreatedBy(?User $createdBy): self
{
$this->createdBy = $createdBy;
return $this;
}
public function getCreatedAt(): ?DateTime
{
return $this->createdAt;
}
public function setCreatedAt(DateTime $createdAt): self
{
$this->createdAt = $createdAt;
return $this;
}
public function setCreatedAtNowNoSeconds(): self public function setCreatedAtNowNoSeconds(): self
{ {
$this->createdAt = DateTime::createFromFormat('Y-m-d H:i', date('Y-m-d H:i')); $this->setCreatedAt(DateTime::createFromFormat('Y-m-d H:i', date('Y-m-d H:i')));
return $this; return $this;
} }
public function setCreatedAtNow(): self public function setCreatedAtNow(): self
{ {
$this->createdAt = new DateTime(); $this->setCreatedAt(new DateTime());
return $this; return $this;
} }
} }

View File

@ -17,34 +17,34 @@ class Snip
#[ORM\Id] #[ORM\Id]
#[ORM\GeneratedValue] #[ORM\GeneratedValue]
#[ORM\Column] #[ORM\Column]
public ?int $id = null; private ?int $id = null;
#[ORM\Column(length: 255)] #[ORM\Column(length: 255)]
public ?string $name = null; private ?string $name = null;
#[ORM\Column] #[ORM\Column]
public bool $public = false; private bool $public = false;
#[ORM\OneToMany(mappedBy: 'snip', targetEntity: SnipContent::class, orphanRemoval: true)] #[ORM\OneToMany(mappedBy: 'snip', targetEntity: SnipContent::class, orphanRemoval: true)]
public Collection $snipContents; private Collection $snipContents;
#[ORM\OneToOne] #[ORM\OneToOne]
public ?SnipContent $activeVersion = null; private ?SnipContent $activeVersion = null;
#[ORM\Column(length: 255)] #[ORM\Column(length: 255)]
public ?string $parser = null; private ?string $parser = null;
#[ORM\Column] #[ORM\Column]
public bool $visible = true; private bool $visible = true;
#[ORM\Column] #[ORM\Column]
public bool $archived = false; private bool $archived = false;
/** /**
* @var Collection<int, Tag> * @var Collection<int, Tag>
*/ */
#[ORM\ManyToMany(targetEntity: Tag::class, mappedBy: 'snips')] #[ORM\ManyToMany(targetEntity: Tag::class, mappedBy: 'snips')]
public Collection $tags; private Collection $tags;
public function __construct() public function __construct()
{ {
@ -59,14 +59,51 @@ class Snip
public function getActiveText(): string public function getActiveText(): string
{ {
return SnipContentService::rebuildText($this->activeVersion); return SnipContentService::rebuildText($this->getActiveVersion());
}
public function getId(): ?int
{
return $this->id;
}
public function getName(): ?string
{
return $this->name;
}
public function setName(string $name): self
{
$this->name = $name;
return $this;
}
public function isPublic(): ?bool
{
return $this->public;
}
public function setPublic(bool $public): self
{
$this->public = $public;
return $this;
}
/**
* @return Collection<int, SnipContent>
*/
public function getSnipContents(): Collection
{
return $this->snipContents;
} }
public function addSnipContent(SnipContent $snipContent): self public function addSnipContent(SnipContent $snipContent): self
{ {
if (!$this->snipContents->contains($snipContent)) { if (!$this->snipContents->contains($snipContent)) {
$this->snipContents->add($snipContent); $this->snipContents->add($snipContent);
$snipContent->snip = $this; $snipContent->setSnip($this);
} }
return $this; return $this;
@ -76,8 +113,8 @@ class Snip
{ {
if ($this->snipContents->removeElement($snipContent)) { if ($this->snipContents->removeElement($snipContent)) {
// set the owning side to null (unless already changed) // set the owning side to null (unless already changed)
if ($snipContent->snip === $this) { if ($snipContent->getSnip() === $this) {
$snipContent->snip = null; $snipContent->setSnip(null);
} }
} }
@ -89,6 +126,62 @@ class Snip
return $this->snipContents->last() ?: null; return $this->snipContents->last() ?: null;
} }
public function getActiveVersion(): ?SnipContent
{
return $this->activeVersion;
}
public function setActiveVersion(?SnipContent $activeVersion): static
{
$this->activeVersion = $activeVersion;
return $this;
}
public function getParser(): ?string
{
return $this->parser;
}
public function setParser(string $parser): static
{
$this->parser = $parser;
return $this;
}
public function isVisible(): ?bool
{
return $this->visible;
}
public function setVisible(bool $visible): static
{
$this->visible = $visible;
return $this;
}
public function isArchived(): ?bool
{
return $this->archived;
}
public function setArchived(bool $archived): static
{
$this->archived = $archived;
return $this;
}
/**
* @return Collection<int, Tag>
*/
public function getTags(): Collection
{
return $this->tags;
}
public function addTag(Tag $tag): static public function addTag(Tag $tag): static
{ {
if (!$this->tags->contains($tag)) { if (!$this->tags->contains($tag)) {

View File

@ -17,30 +17,129 @@ class SnipContent
#[ORM\Column(type: UlidType::NAME, unique: true)] #[ORM\Column(type: UlidType::NAME, unique: true)]
#[ORM\GeneratedValue(strategy: 'CUSTOM')] #[ORM\GeneratedValue(strategy: 'CUSTOM')]
#[ORM\CustomIdGenerator(class: 'doctrine.ulid_generator')] #[ORM\CustomIdGenerator(class: 'doctrine.ulid_generator')]
public ?Ulid $id = null; private ?Ulid $id = null;
#[ORM\ManyToOne(inversedBy: 'snipContents')] #[ORM\ManyToOne(inversedBy: 'snipContents')]
#[ORM\JoinColumn(nullable: false)] #[ORM\JoinColumn(nullable: false)]
public ?Snip $snip = null; private ?Snip $snip = null;
#[ORM\ManyToOne(targetEntity: self::class, inversedBy: 'children')] #[ORM\ManyToOne(targetEntity: self::class, inversedBy: 'children')]
public ?self $parent = null; private ?self $parent = null;
/** @var Collection<int, self> */ #[ORM\OneToMany(mappedBy: 'parent', targetEntity: self::class)]
#[ORM\OneToMany(targetEntity: self::class, mappedBy: 'parent')] private Collection $children;
public Collection $children;
#[ORM\Column(type: Types::TEXT, nullable: true)] #[ORM\Column(type: Types::TEXT, nullable: true)]
public ?string $text = null; private ?string $text = null;
#[ORM\Column(nullable: true)] #[ORM\Column(nullable: true)]
public ?array $diff = null; private ?array $diff = null;
#[ORM\Column(length: 255, nullable: true)] #[ORM\Column(length: 255, nullable: true)]
public ?string $name = null; private ?string $name = null;
public function __construct() public function __construct()
{ {
$this->children = new ArrayCollection(); $this->children = new ArrayCollection();
} }
public function __toString(): string
{
return $this->name ?? $this->id->toBase32();
}
public function getId(): ?Ulid
{
return $this->id;
}
public function getSnip(): ?Snip
{
return $this->snip;
}
public function setSnip(?Snip $snip): self
{
$this->snip = $snip;
return $this;
}
public function getParent(): ?self
{
return $this->parent;
}
public function setParent(?self $parent): self
{
$this->parent = $parent;
return $this;
}
/**
* @return Collection<int, self>
*/
public function getChildren(): Collection
{
return $this->children;
}
public function addChild(self $child): self
{
if (!$this->children->contains($child)) {
$this->children->add($child);
$child->setParent($this);
}
return $this;
}
public function removeChild(self $child): self
{
if ($this->children->removeElement($child)) {
// set the owning side to null (unless already changed)
if ($child->getParent() === $this) {
$child->setParent(null);
}
}
return $this;
}
public function getText(): ?string
{
return $this->text;
}
public function setText(?string $text): self
{
$this->text = $text;
return $this;
}
public function getDiff(): ?array
{
return $this->diff;
}
public function setDiff(?array $diff): static
{
$this->diff = $diff;
return $this;
}
public function getName(): ?string
{
return $this->name;
}
public function setName(?string $name): static
{
$this->name = $name;
return $this;
}
} }

View File

@ -16,22 +16,22 @@ class Tag
#[ORM\Id] #[ORM\Id]
#[ORM\GeneratedValue] #[ORM\GeneratedValue]
#[ORM\Column] #[ORM\Column]
public ?int $id = null; private ?int $id = null;
#[ORM\Column(length: 255)] #[ORM\Column(length: 255)]
#[Assert\NotEqualTo(SnipFilterRequest::TAG_ALL)] #[Assert\NotEqualTo(SnipFilterRequest::TAG_ALL)]
#[Assert\NotEqualTo(SnipFilterRequest::TAG_NONE)] #[Assert\NotEqualTo(SnipFilterRequest::TAG_NONE)]
public ?string $name = null; private ?string $name = null;
#[ORM\ManyToOne] #[ORM\ManyToOne]
#[ORM\JoinColumn(nullable: false)] #[ORM\JoinColumn(nullable: false)]
public ?User $user = null; private ?User $user = null;
/** /**
* @var Collection<int, Snip> * @var Collection<int, Snip>
*/ */
#[ORM\ManyToMany(targetEntity: Snip::class, inversedBy: 'tags')] #[ORM\ManyToMany(targetEntity: Snip::class, inversedBy: 'tags')]
public Collection $snips; private Collection $snips;
public function __construct() public function __construct()
{ {
@ -43,6 +43,43 @@ class Tag
return $this->name ?? ''; return $this->name ?? '';
} }
public function getId(): ?int
{
return $this->id;
}
public function getName(): ?string
{
return $this->name;
}
public function setName(string $name): static
{
$this->name = $name;
return $this;
}
public function getUser(): ?User
{
return $this->user;
}
public function setUser(?User $user): static
{
$this->user = $user;
return $this;
}
/**
* @return Collection<int, Snip>
*/
public function getSnips(): Collection
{
return $this->snips;
}
public function addSnip(Snip $snip): static public function addSnip(Snip $snip): static
{ {
if (!$this->snips->contains($snip)) { if (!$this->snips->contains($snip)) {

View File

@ -34,7 +34,7 @@ class TagsType extends AbstractType implements DataTransformerInterface
} }
if (is_array($value)) { if (is_array($value)) {
$tags = array_map(fn(Tag $tag) => $tag->name, $value); $tags = array_map(fn(Tag $tag) => $tag->getName(), $value);
} else { } else {
return ''; return '';
} }
@ -51,8 +51,7 @@ class TagsType extends AbstractType implements DataTransformerInterface
$tagEntity = $this->repository->findOneBy(['name' => $tag, 'user' => $user]); $tagEntity = $this->repository->findOneBy(['name' => $tag, 'user' => $user]);
if ($tagEntity === null) { if ($tagEntity === null) {
$tagEntity = new Tag(); $tagEntity = new Tag();
$tagEntity->name = $tag; $tagEntity->setName($tag)->setUser($user);
$tagEntity->user = $user;
// Validate the new Tag entity // Validate the new Tag entity
$errors = $this->validator->validate($tagEntity); $errors = $this->validator->validate($tagEntity);
@ -91,4 +90,4 @@ class TagsType extends AbstractType implements DataTransformerInterface
{ {
return TextType::class; return TextType::class;
} }
} }

View File

@ -4,14 +4,13 @@ namespace App\Security\Voter;
use App\Entity\Snip; use App\Entity\Snip;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter; use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Core\User\UserInterface;
class SnipVoter extends Voter class SnipVoter extends Voter
{ {
public const string EDIT = 'edit'; public const EDIT = 'edit';
public const string VIEW = 'view'; public const VIEW = 'view';
protected function supports(string $attribute, mixed $subject): bool protected function supports(string $attribute, mixed $subject): bool
{ {
@ -21,7 +20,7 @@ class SnipVoter extends Voter
&& $subject instanceof Snip; && $subject instanceof Snip;
} }
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token,/* , ?Vote $vote = null */): bool protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{ {
/** @var Snip $subject */ /** @var Snip $subject */
@ -29,14 +28,14 @@ class SnipVoter extends Voter
switch ($attribute) { switch ($attribute) {
case self::VIEW: case self::VIEW:
if ($subject->public) { if ($subject->isPublic()) {
return true; return true;
} }
case self::EDIT: case self::EDIT:
if (!$user instanceof UserInterface) { if (!$user instanceof UserInterface) {
return false; return false;
} }
if ($subject->createdBy === $user) { if ($subject->getCreatedBy() === $user) {
return true; return true;
} }
break; break;

View File

@ -0,0 +1,37 @@
<?php
namespace App\Service\SnipContent;
use App\Entity\Snip;
use Symfony\Component\Routing\RouterInterface;
readonly class FlowChartTreeBuilder
{
public function __construct(
private RouterInterface $router,
) {}
public function buildTree(Snip $snip): array
{
$tree = [];
foreach ($snip->getSnipContents() as $content) {
if ($content->getParent()) {
$tree[] = sprintf('%s --> %s', $content->getParent(), $content);
}
}
foreach ($snip->getSnipContents() as $content) {
$tree[] = sprintf(
'click %s href "%s"',
$content,
$this->router->generate('version_set', ['snip' => $snip->getId(), 'version' => $content->getId()])
);
$tree[] = sprintf('%s@{ shape: rounded }', $content);
}
$tree[] = sprintf('class %s active', $snip->getActiveVersion());
return $tree;
}
}

View File

@ -0,0 +1,61 @@
<?php
namespace App\Service\SnipContent;
use App\Entity\Snip;
use App\Entity\SnipContent;
class GitTreeBuilder
{
private string $activeBranch = 'main';
public function __construct(private readonly Snip $snip) {}
public function buildTree(SnipContent $content, string $branch = 'main'): array
{
$tree = [];
if ($this->activeBranch !== $branch) {
$tree[] = $this->checkout($branch);
}
$commit = sprintf('commit id:"%s"', $content);
if ($this->snip->getActiveVersion() === $content) {
$commit .= ' tag: "active" type: REVERSE';
}
$tree[] = $commit;
$first = true;
foreach ($content->getChildren() as $child) {
if (!$first) {
$tree[] = $this->branch($child);
$tree[] = $this->checkout($branch);
}
$first = false;
}
$first = true;
foreach ($content->getChildren() as $child) {
if ($first) {
$myBranch = $branch;
} else {
$tree[] = $this->checkout($child);
$myBranch = $child;
}
$tree = array_merge($tree, self::buildTree($child, $myBranch));
$first = false;
}
return $tree;
}
private function branch(string $branch): string
{
$this->activeBranch = $branch;
return sprintf('branch %s', $branch);
}
private function checkout(string $branch): string
{
$this->activeBranch = $branch;
return sprintf('checkout %s', $branch);
}
}

View File

@ -15,26 +15,27 @@ readonly class SnipContentService
public function update(Snip $snip, string $contents, ?string $contentName): void public function update(Snip $snip, string $contents, ?string $contentName): void
{ {
$parentContent = $snip->activeVersion; $parentContent = $snip->getActiveVersion();
if (self::rebuildText($parentContent) === $contents) { if (self::rebuildText($parentContent) === $contents) {
return; return;
} }
// Create new snipContent entity with previous one as parent // Create new snipContent entity with previous one as parent
$content = new SnipContent(); $content = new SnipContent();
$content->text = $contents; $content
$content->snip = $snip; ->setText($contents)
$content->name = $contentName; ->setSnip($snip)
->setName($contentName)
;
if ($parentContent !== null) { if ($parentContent !== null) {
$content->parent = $parentContent; $content->setParent($parentContent);
$this->contentToRelative($parentContent); $this->contentToRelative($parentContent);
} }
$this->em->persist($content); $this->em->persist($content);
$this->em->flush(); $this->em->flush();
$snip->activeVersion = $content; $snip->setActiveVersion($content);
$this->em->persist($snip); $this->em->persist($snip);
$this->em->flush(); $this->em->flush();
} }
@ -44,53 +45,53 @@ readonly class SnipContentService
if ($snipContent === null) { if ($snipContent === null) {
return ''; return '';
} }
if ($snipContent->text) { if ($snipContent->getText()) {
return $snipContent->text; return $snipContent->getText();
} }
$parentContent = $snipContent->parent; $parentContent = $snipContent->getParent();
if ($parentContent === null && $snipContent->diff === null) { if ($parentContent === null && $snipContent->getDiff() === null) {
return '---Something went very wrong, cant rebuild the text---'; return '---Something went very wrong, cant rebuild the text---';
} }
return MyersDiff::rebuildBFromCompact( return MyersDiff::rebuildBFromCompact(
self::rebuildText($parentContent), $snipContent->diff self::rebuildText($parentContent), $snipContent->getDiff()
); );
} }
public function setVersion(Snip $snip, SnipContent $version): void public function setVersion(Snip $snip, SnipContent $version): void
{ {
$activeVersion = $snip->activeVersion; $activeVersion = $snip->getActiveVersion();
$this->contentToAbsolute($version); $this->contentToAbsolute($version);
$this->contentToRelative($activeVersion); $this->contentToRelative($activeVersion);
$snip->activeVersion = $version; $snip->setActiveVersion($version);
$this->em->persist($snip); $this->em->persist($snip);
$this->em->flush(); $this->em->flush();
} }
public function contentToRelative(SnipContent $content): void public function contentToRelative(SnipContent $content): void
{ {
if ($content->text === null || $content->parent === null) { if ($content->getText() === null || $content->getParent() === null) {
return; return;
} }
$contentText = $content->text; $contentText = $content->getText();
$parentText = self::rebuildText($content->parent); $parentText = self::rebuildText($content->getParent());
$diff = MyersDiff::calculate($parentText, $contentText); $diff = MyersDiff::calculate($parentText, $contentText);
$content->diff = $diff; $content->setDiff($diff);
$content->text = null; $content->setText(null);
$this->em->persist($content); $this->em->persist($content);
$this->em->flush(); $this->em->flush();
} }
public function contentToAbsolute(SnipContent $content): void public function contentToAbsolute(SnipContent $content): void
{ {
if ($content->diff === null) { if ($content->getDiff() === null) {
return; return;
} }
$content->text = self::rebuildText($content); $content->setText(self::rebuildText($content));
$content->diff = null; $content->setDiff(null);
$this->em->persist($content); $this->em->persist($content);
$this->em->flush(); $this->em->flush();
} }
} }

View File

@ -20,7 +20,7 @@ abstract class AbstractParser implements ParserInterface
try { try {
return $this->safeParseView($content); return $this->safeParseView($content);
} catch (\Exception $exception) { } catch (\Exception $exception) {
return sprintf('<pre><code>%s</code></pre>', htmlspecialchars($exception->getMessage())); return sprintf('<pre><code class="hljs">%s</code></pre>', htmlspecialchars($exception->getMessage()));
} }
} }

View File

@ -17,9 +17,9 @@ class GenericParser extends AbstractParser
$builder = new PipelineBuilder(); $builder = new PipelineBuilder();
$pipeline = $builder $pipeline = $builder
->add(new HtmlEscapeStage()) ->add(new HtmlEscapeStage())
// ->add(new ReplaceBlocksStage('<pre>', '</pre>', '```'))
// ->add(new ReplaceBlocksStage('<code>', '</code>', '``'))
->add(new ReplaceStage(PHP_EOL, '<br>')) ->add(new ReplaceStage(PHP_EOL, '<br>'))
->add(new ReplaceBlocksStage('<pre><code class="hljs">', '</code></pre>', '```'))
->add(new ReplaceBlocksStage('<code class="hljs">', '</code>', '``'))
->add($this->referenceStage) ->add($this->referenceStage)
->add($this->includeStage) ->add($this->includeStage)
->build() ->build()
@ -27,4 +27,13 @@ class GenericParser extends AbstractParser
return $pipeline->process($content); return $pipeline->process($content);
} }
public function parseRaw(string $content): string
{
return str_replace(
['```', '``'],
'',
$content
);
}
} }

View File

@ -37,11 +37,11 @@ class IncludeReferenceStage implements StageInterface
$content = null; $content = null;
} }
if ($content) { if ($content) {
$snip = $content->snip; $snip = $content->getSnip();
} else { } else {
$snip = $this->snipRepository->find($id); $snip = $this->snipRepository->find($id);
if ($snip) { if ($snip) {
$content = $this->snipContentRepository->find($snip->activeVersion); $content = $this->snipContentRepository->find($snip->getActiveVersion());
} }
} }
if ($content === null) { if ($content === null) {
@ -56,4 +56,4 @@ class IncludeReferenceStage implements StageInterface
); );
}, $payload); }, $payload);
} }
} }

View File

@ -4,14 +4,13 @@ namespace App\Service\SnipParser\Generic;
use InvalidArgumentException; use InvalidArgumentException;
use League\Pipeline\StageInterface; use League\Pipeline\StageInterface;
use Tempest\Highlight\Highlighter;
readonly class ReplaceBlocksStage implements StageInterface class ReplaceBlocksStage implements StageInterface
{ {
public function __construct( public function __construct(
public string $openTag = '<pre><code>', public readonly string $openTag = '<pre><code>',
public string $closeTag = '</code></pre>', public readonly string $closeTag = '</code></pre>',
public string $delimiter = '```' public readonly string $delimiter = '```'
) {} ) {}
public function __invoke(mixed $payload): string public function __invoke(mixed $payload): string
@ -27,9 +26,8 @@ readonly class ReplaceBlocksStage implements StageInterface
{ {
$pattern = sprintf('/%s(.+?)%s/s', preg_quote($this->delimiter), preg_quote($this->delimiter)); $pattern = sprintf('/%s(.+?)%s/s', preg_quote($this->delimiter), preg_quote($this->delimiter));
$highlighter = new Highlighter()->withGutter(); return preg_replace_callback($pattern, function ($matches) {
return preg_replace_callback($pattern, function ($matches) use ($highlighter) { return $this->openTag . trim($matches[1]) . $this->closeTag;
return $this->openTag . $highlighter->parse(trim($matches[1]), 'php') . $this->closeTag;
}, $text); }, $text);
} }
} }

View File

@ -36,8 +36,8 @@ readonly class UrlReferenceStage implements StageInterface
return sprintf('<span title="access denied">%s</span>', $matches[0]); return sprintf('<span title="access denied">%s</span>', $matches[0]);
} }
$url = $this->router->generate('snip_single', ['snip' => $snip->id]); $url = $this->router->generate('snip_single', ['snip' => $snip->getId()]);
return sprintf('<a href="%s">%s</a>', $url, $snip); return sprintf('<a href="%s">%s</a>', $url, $snip);
}, $payload); }, $payload);
} }
} }

View File

@ -3,14 +3,11 @@
namespace App\Service\SnipParser\Html; namespace App\Service\SnipParser\Html;
use App\Service\SnipParser\AbstractParser; use App\Service\SnipParser\AbstractParser;
use Tempest\Highlight\Highlighter;
class HtmlParser extends AbstractParser class HtmlParser extends AbstractParser
{ {
public function safeParseView(string $content): string public function safeParseView(string $content): string
{ {
$highlighter = new Highlighter()->withGutter(); return sprintf('<pre><code class="hljs">%s</code></pre>', htmlspecialchars($content));
return '<pre data-lang="html" class="notranslate">' . $highlighter->parse($content, 'html') . '</pre>';
} }
} }

View File

@ -6,16 +6,11 @@ use App\Repository\SnipRepository;
use App\Service\SnipParser\AbstractParser; use App\Service\SnipParser\AbstractParser;
use League\CommonMark\Event\DocumentParsedEvent; use League\CommonMark\Event\DocumentParsedEvent;
use League\CommonMark\Extension\CommonMark\Node\Inline\Link; use League\CommonMark\Extension\CommonMark\Node\Inline\Link;
use League\CommonMark\Extension\DefaultAttributes\DefaultAttributesExtension;
use League\CommonMark\Extension\Footnote\FootnoteExtension;
use League\CommonMark\Extension\Table\Table;
use League\CommonMark\GithubFlavoredMarkdownConverter; use League\CommonMark\GithubFlavoredMarkdownConverter;
use League\CommonMark\Node\Inline\Text; use League\CommonMark\Node\Inline\Text;
use League\CommonMark\Node\Query; use League\CommonMark\Node\Query;
use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Routing\RouterInterface; use Symfony\Component\Routing\RouterInterface;
use Tempest\Highlight\CommonMark\HighlightExtension;
use Tempest\Highlight\Highlighter;
class MarkdownParser extends AbstractParser class MarkdownParser extends AbstractParser
{ {
@ -26,25 +21,8 @@ class MarkdownParser extends AbstractParser
public function safeParseView(string $content): string public function safeParseView(string $content): string
{ {
$config = [ $converter = new GithubFlavoredMarkdownConverter();
'default_attributes' => [ $converter->getEnvironment()->addEventListener(DocumentParsedEvent::class, $this->documentParsed(...));
Table::class => [
'class' => 'table table-hover',
],
Link::class => [
'class' => 'btn btn-sm btn-secondary',
],
],
];
$converter = new GithubFlavoredMarkdownConverter($config);
$converter
->getEnvironment()
->addExtension(new HighlightExtension(new Highlighter()->withGutter()))
->addExtension(new FootnoteExtension())
->addExtension(new DefaultAttributesExtension())
->addEventListener(DocumentParsedEvent::class, $this->documentParsed(...))
;
return $converter->convert($content); return $converter->convert($content);
} }
@ -54,17 +32,11 @@ class MarkdownParser extends AbstractParser
$linkNodes = new Query() $linkNodes = new Query()
->where(Query::type(Link::class)) ->where(Query::type(Link::class))
->findAll($document) ->findAll($document);
;
/** @var Link $linkNode */
foreach ($linkNodes as $linkNode) { foreach ($linkNodes as $linkNode) {
$url = $linkNode->getUrl(); $url = $linkNode->getUrl();
if (!is_numeric($url)) {
continue;
}
$snip = $this->snipRepo->find($url); $snip = $this->snipRepo->find($url);
if ($snip === null) { if ($snip === null) {
continue; continue;

View File

@ -29,9 +29,9 @@ readonly class ParserFactory
public function getBySnip(Snip $snip): ParserInterface public function getBySnip(Snip $snip): ParserInterface
{ {
$parser = $snip->parser; $parser = $snip->getParser();
if (null === $parser) { if (null === $parser) {
throw new ServiceNotFoundException(sprintf('Unknown parser for snip "%s"', $snip->parser)); throw new ServiceNotFoundException(sprintf('Unknown parser for snip "%s"', $snip->getParser()));
} }
return $this->get($parser); return $this->get($parser);
@ -49,4 +49,4 @@ readonly class ParserFactory
{ {
foreach ($this->getAll() as $parser) yield $parser::getName(); foreach ($this->getAll() as $parser) yield $parser::getName();
} }
} }

View File

@ -25,7 +25,7 @@ class SnipLoader implements LoaderInterface
public function getCacheKey(string $name): string public function getCacheKey(string $name): string
{ {
return $this->getFromKey($name)->activeVersion->id; return $this->getFromKey($name)->getActiveVersion()->getId();
} }
public function isFresh(string $name, int $time): bool public function isFresh(string $name, int $time): bool
@ -58,4 +58,4 @@ class SnipLoader implements LoaderInterface
return $snip; return $snip;
} }
} }

View File

@ -56,8 +56,8 @@ class SnipTwigExtension extends AbstractExtension
$request = new SnipFilterRequest(SnipFilterRequest::VISIBILITY_ALL, tag: $tag); $request = new SnipFilterRequest(SnipFilterRequest::VISIBILITY_ALL, tag: $tag);
$snips = $this->snipRepo->findByRequest($user, $request); $snips = $this->snipRepo->findByRequest($user, $request);
return array_map(fn(Snip $snip) => [ return array_map(fn(Snip $snip) => [
'id' => $snip->id, 'id' => $snip->getId(),
'name' => $snip->name, 'name' => $snip->getName(),
], $snips); ], $snips);
} }
} }

View File

@ -1,13 +1,4 @@
{ {
"doctrine/deprecations": {
"version": "1.1",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "1.0",
"ref": "87424683adc81d7dc305eefec1fced883084aab9"
}
},
"doctrine/doctrine-bundle": { "doctrine/doctrine-bundle": {
"version": "2.14", "version": "2.14",
"recipe": { "recipe": {
@ -85,15 +76,14 @@
] ]
}, },
"symfony/framework-bundle": { "symfony/framework-bundle": {
"version": "7.3", "version": "7.2",
"recipe": { "recipe": {
"repo": "github.com/symfony/recipes", "repo": "github.com/symfony/recipes",
"branch": "main", "branch": "main",
"version": "7.3", "version": "7.2",
"ref": "5a1497d539f691b96afd45ae397ce5fe30beb4b9" "ref": "87bcf6f7c55201f345d8895deda46d2adbdbaa89"
}, },
"files": [ "files": [
".editorconfig",
"config/packages/cache.yaml", "config/packages/cache.yaml",
"config/packages/framework.yaml", "config/packages/framework.yaml",
"config/preload.php", "config/preload.php",
@ -125,18 +115,6 @@
"config/packages/monolog.yaml" "config/packages/monolog.yaml"
] ]
}, },
"symfony/property-info": {
"version": "7.3",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.3",
"ref": "dae70df71978ae9226ae915ffd5fad817f5ca1f7"
},
"files": [
"config/packages/property_info.yaml"
]
},
"symfony/routing": { "symfony/routing": {
"version": "7.2", "version": "7.2",
"recipe": { "recipe": {
@ -199,12 +177,12 @@
] ]
}, },
"symfony/web-profiler-bundle": { "symfony/web-profiler-bundle": {
"version": "7.3", "version": "7.2",
"recipe": { "recipe": {
"repo": "github.com/symfony/recipes", "repo": "github.com/symfony/recipes",
"branch": "main", "branch": "main",
"version": "7.3", "version": "6.1",
"ref": "a363460c1b0b4a4d0242f2ce1a843ca0f6ac9026" "ref": "8b51135b84f4266e3b4c8a6dc23c9d1e32e543b7"
}, },
"files": [ "files": [
"config/packages/web_profiler.yaml", "config/packages/web_profiler.yaml",

View File

@ -3,11 +3,8 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title> <title>{% if title is defined %}{{ title }}{% else %}SNIPS{% endif %}</title>
{% if app.environment == 'dev' %}D{% endif %} <link rel="shortcut icon" type="image/jpg" href="/favicon.png">
{% if title is defined %}{{ title }}{% else %}SNIPS{% endif %}
</title>
<link rel="shortcut icon" type="image/jpg" href="/snips.png">
{% block css %} {% block css %}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
@ -46,7 +43,9 @@
{# javascript block #} {# javascript block #}
{% block js %} {% block js %}
<script src="https://kit.fontawesome.com/3471b6556e.js" crossorigin="anonymous"></script> <script src="https://kit.fontawesome.com/3471b6556e.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.min.js"
integrity="sha384-QJHtvGhmr9XOIpI6YVutG+2QOK9T+ZnN4kzFN1RtK3zEFEIsxhlmWl5/YESvpZ13"
crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/jquery@3.6.0/dist/jquery.min.js" <script src="https://cdn.jsdelivr.net/npm/jquery@3.6.0/dist/jquery.min.js"
integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4="
crossorigin="anonymous"></script> crossorigin="anonymous"></script>

View File

@ -1,9 +1,6 @@
<nav class="navbar navbar-expand-md navbar-dark bg-dark" style="z-index: 1;"> <nav class="navbar navbar-expand-md navbar-dark bg-dark" style="z-index: 1;">
<div class="container-fluid"> <div class="container-fluid">
<a title="Snips" class="navbar-brand" href="{{ path('home') }}"> <a title="Snips" class="navbar-brand" href="{{ path('home') }}">SNIPS</a>
<img src="/snips.png" width="30" height="30" class="d-inline-block align-top rounded" alt="">
SNIPS
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbar" <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbar"
aria-controls="navbar" aria-expanded="false" aria-label="Toggle navigation"> aria-controls="navbar" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span> <span class="navbar-toggler-icon"></span>

View File

@ -1,56 +0,0 @@
{% extends 'base/one.column.html.twig' %}
{% block body %}
{% if app.user and app.user is same as(snip.createdBy) %}
<a href="{{ path('snip_index') }}" class="btn btn-primary">
<i class="fa fa-arrow-left"></i> Index
</a>
{% else %}
<a href="{{ path('snip_public') }}" class="btn btn-primary">
<i class="fa fa-arrow-left"></i> Index
</a>
{% endif %}
{% block buttons %}{% endblock %}
<br><br>
<div class="card" style="width: 100%;">
<div class="card-header d-flex justify-content-between">
<ul class="nav nav-tabs card-header-tabs">
<li class="nav-item">
<a class="nav-link {% if active == 'single' %}active{% endif %}"
href="{{ path('snip_single', {'snip': snip.id}) }}">View</a>
</li>
{% if is_granted('edit', snip) %}
<li class="nav-item">
<a class="nav-link {% if active == 'edit' %}active{% endif %}"
href="{{ path('snip_edit', {'snip': snip.id}) }}">Edit</a>
</li>
<li class="nav-item">
<a class="nav-link {% if active == 'versions' %}active{% endif %}"
href="{{ path('version_index', {'snip': snip.id}) }}">Versions</a>
</li>
{% endif %}
</ul>
<span>
<span class="badge bg-secondary">
<i class="fa fa-hashtag"></i> {{ snip.id }}
</span>
{% for tag in snip.tags %}
<span class="badge bg-secondary">{{ tag }}</span>
{% endfor %}
{{ include('user/badge.html.twig', {user: snip.createdBy}) }}
{{ include('snip/badge.html.twig', {snip: snip}) }}
</span>
</div>
<div class="card-body">
{% block cardbody %}{% endblock %}
</div>
<div class="card-footer">
<p class="card-text text-muted">
Current version: {{ snip.activeVersion.id }}
{% if snip.activeVersion == snip.latestVersion %}(latest){% endif %}
Created at {{ include('generic/datetime.badge.html.twig', {datetime: snip.activeVersion.id.dateTime}) }}
</p>
</div>
</div>
{% endblock %}

View File

@ -1,7 +0,0 @@
{% extends 'base/one.column.html.twig' %}
{% set title = 'Create Snip' %}
{% block body %}
{{ form(form) }}
{% endblock %}

View File

@ -1,23 +1,21 @@
{% extends 'snip/base.html.twig' %} {% extends 'base/one.column.html.twig' %}
{% set title %}{{ snip }} - Edit{% endset %} {% if snip.id %}
{% set active = 'edit' %} {% set title = 'Edit Snip ' ~ snip %}
{% else %}
{% set title = 'Create Snip' %}
{% endif %}
{% block buttons %} {% block body %}
{% if is_granted('edit', snip) %} {% if snip.id %}
<a href="{{ path('snip_archive', {snip: snip.id}) }}" class="btn btn-secondary"> <a href="{{ path('snip_single', {snip: snip.id}) }}" class="btn btn-primary">
{% if snip.archived %} <i class="fa fa-arrow-left"></i>
<i class="fa fa-undo"></i> Unarchive Back
{% else %}
<i class="fa fa-archive"></i> Archive
{% endif %}
</a>
<a href="{{ path('snip_delete', {snip: snip.id}) }}" class="btn btn-danger">
<i class="fa fa-trash"></i> Delete
</a> </a>
{% endif %} {% endif %}
{% endblock %} <a href="{{ path('snip_index') }}" class="btn btn-info">
<i class="fa fa-list"></i>
{% block cardbody %} Index
</a><br><br>
{{ form(form) }} {{ form(form) }}
{% endblock %} {% endblock %}

View File

@ -1,19 +1,79 @@
{% extends 'snip/base.html.twig' %} {% extends 'base/one.column.html.twig' %}
{% set title %}{{ snip }} - View{% endset %} {% set title %}Snip {{ snip }}{% endset %}
{% set active = 'single' %}
{% block buttons %} {% block body %}
<a href="{{ path('snip_raw', {snip: snip.id}) }}" class="btn btn-info"> {% if app.user %}
<a href="{{ path('snip_index') }}" class="btn btn-primary">
<i class="fa fa-arrow-left"></i> Back
</a>
{% else %}
<a href="{{ path('snip_public') }}" class="btn btn-primary">
<i class="fa fa-arrow-left"></i> Index
</a>
{% endif %}
{% if is_granted('edit', snip) %}
<a class="btn btn-info" href="{{ path('version_index', {snip: snip.id}) }}">
<i class="fa fa-history" aria-hidden="true"></i> Versions
</a>
<a class="btn btn-warning" href="{{ path('snip_edit', {snip: snip.id}) }}">
<i class="fa fa-pencil" aria-hidden="true"></i> Edit
</a>
<a href="{{ path('snip_archive', {snip: snip.id}) }}" class="btn btn-secondary">
{% if snip.archived %}
<i class="fa fa-undo"></i> Unarchive
{% else %}
<i class="fa fa-archive"></i> Archive
{% endif %}
</a>
<a href="{{ path('snip_delete', {snip: snip.id}) }}" class="btn btn-danger">
<i class="fa fa-trash"></i> Delete
</a>
{% endif %}
<a href="{{ path('snip_raw', {snip: snip.id}) }}" class="btn btn-primary">
<i class="fa fa-eye"></i> Raw <i class="fa fa-eye"></i> Raw
</a> </a>
{% endblock %} <br><br>
<div class="card" style="width: 100%;">
{% block cardbody %} <h4 class="card-header d-flex justify-content-between">
{{ content|raw }} <span>
{{ snip }} <small class="text-muted">#{{ snip.id }}</small>
</span>
<span>
{% for tag in snip.tags %}
<span class="badge bg-secondary">{{ tag }}</span>
{% endfor %}
{{ include('user/badge.html.twig', {user: snip.createdBy}) }}
{{ include('snip/badge.html.twig', {snip: snip}) }}
</span>
</h4>
<div class="card-body">
{{ content|raw }}
</div>
<div class="card-footer">
<p class="card-text text-muted">
Current version: {{ snip.activeVersion.id }}
{% if snip.activeVersion == snip.latestVersion %}(latest){% endif %}
Created at {{ include('generic/datetime.badge.html.twig', {datetime: snip.activeVersion.id.dateTime}) }}
</p>
</div>
</div>
{% endblock %} {% endblock %}
{% block css %} {% block css %}
{{ parent() }} {{ parent() }}
<link rel="stylesheet" href="{{ asset('github-light-default.css') }}"> <link rel="stylesheet"
href="//cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/styles/default.min.css">
{% endblock %} {% endblock %}
{% block js %}
{{ parent() }}
<script src="//cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/highlight.min.js"></script>
<script>
const codeBlocks = document.querySelectorAll('code.hljs');
codeBlocks.forEach((block) => {
hljs.highlightElement(block);
});
</script>
{% endblock %}

View File

@ -1,3 +1 @@
<span class="badge {% if user == app.user %}bg-success{% else %}bg-secondary{% endif %}"> <span class="badge {% if user == app.user %}bg-success{% else %}bg-secondary{% endif %}">{{ user }}</span>
<i class="fa fa-user"></i> {{ user }}
</span>

View File

@ -1,28 +1,46 @@
{% extends 'snip/base.html.twig' %} {% extends 'base/one.column.html.twig' %}
{% set title %}{{ snip }} - Versions{% endset %} {% set title = 'Snip ' ~ snip %}
{% set active = 'versions' %}
{% block buttons %} {% block body %}
<a href="{{ path('snip_single', {snip: snip.id}) }}" class="btn btn-primary">
<i class="fa fa-arrow-left"></i> Back
</a>
<a href="{{ path('version_set', {version: snip.latestVersion.id, snip: snip.id}) }}" class="btn btn-warning"> <a href="{{ path('version_set', {version: snip.latestVersion.id, snip: snip.id}) }}" class="btn btn-warning">
<i class="fa fa-refresh"></i> Latest <i class="fa fa-refresh"></i> Latest
</a> </a>
<a href="{{ path('content_compare', {to: snip.activeVersion.id}) }}" class="btn btn-info"> <a href="{{ path('content_compare', {to: snip.activeVersion.id}) }}" class="btn btn-info">
<i class="fa fa-left-right"></i> Compare <i class="fa fa-left-right"></i> Compare
</a> </a>
<pre class="mermaid">
flowchart BT
{% for versionData in versions %}
{{~ versionData ~}}
{% endfor %}
</pre>
{% endblock %} {% endblock %}
{% block cardbody %} {% block js %}
<div class="list-group"> {{ parent() }}
{% for version in snip.snipContents|reverse %} <script src="https://cdn.jsdelivr.net/npm/mermaid@11.6.0/dist/mermaid.min.js"></script>
<a class="list-group-item {% if version.id == snip.activeVersion.id %}list-group-item-success{% endif %} d-flex justify-content-between" <script>
href="{{ path('version_set', {version: version.id, snip: snip.id}) }}"> mermaid.initialize({startOnLoad: true});
<span> </script>
{{ include('generic/datetime.badge.html.twig', {datetime: version.id.dateTime}) }} {% endblock %}
{% if version.name %}{{ version.name }}{% endif %}
</span> {% block css %}
<span class="text-muted">{{ version.id }}</span> {{ parent() }}
</a> <style>
{% endfor %} .node rect {
</div> fill: var(--bs-secondary) !important;
stroke: var(--bs-secondary-text-emphasis) !important;
}
.node span {
color: var(--bs-light) !important;
}
.active rect {
fill: var(--bs-success) !important;
stroke: var(--bs-success-text-emphasis) !important;
}
</style>
{% endblock %} {% endblock %}