Compare commits
96 Commits
pregitclea
...
feature/ch
Author | SHA1 | Date | |
---|---|---|---|
933dc424c3 | |||
29df284237 | |||
0dc7555ed1 | |||
2152468bdc | |||
74060da6e5 | |||
a1ecaf0189 | |||
074c1d8570 | |||
8226ac24d8 | |||
94bf0075f0 | |||
f7282ae31a | |||
e40e889c14 | |||
8ec4b2167a | |||
bf7d1efb43 | |||
7bdf9683b4 | |||
f338d791a7 | |||
e26c2a64b8 | |||
33bb4e77e5 | |||
978c075a3e | |||
a741ee102d | |||
5fcc32de6d | |||
797d7a2e8f | |||
39f6aaea23 | |||
116fed5acc | |||
6d20661305 | |||
f6771782e4 | |||
|
73a3b93760 | ||
|
5ae5db985b | ||
|
62136a0ca0 | ||
04b2037f2d | |||
e0ca39933c | |||
e3549f722a | |||
6adc8c4a69 | |||
d64cadb47b | |||
3a16e4609f | |||
951b227efc | |||
42bcd39453 | |||
e8c545af4c | |||
0648db62e3 | |||
b8ae8bb8a7 | |||
e2bd1a7c3b | |||
47ea226ed7 | |||
50a7ab7985 | |||
a7c94a8f21 | |||
cda03f7b67 | |||
771f354346 | |||
ab97482ae6 | |||
06def04cae | |||
f52058c250 | |||
59e068fbf7 | |||
d62d34fb63 | |||
9b699ea4c7 | |||
aeb2d3ee4b | |||
|
6651aefc6b | ||
683cd5074c | |||
aa05a1c98e | |||
38a53c6bd3 | |||
e2c8376482 | |||
74154e240f | |||
f56c78f626 | |||
ba00351201 | |||
8553a06cd0 | |||
7c4a2b46c0 | |||
5a940b9ebd | |||
|
ced70fd650 | ||
|
d7e558cae9 | ||
|
c1b896a63a | ||
3c4c470547 | |||
d906b980c4 | |||
bf55e069e0 | |||
af9f83f189 | |||
0ef77b2aa7 | |||
3a7870a68b | |||
0f6cc78e5e | |||
cc3e050304 | |||
28a2706525 | |||
31cfeca93a | |||
ca7a093e55 | |||
4e16444d2f | |||
701bf6c1ed | |||
943177bc08 | |||
5cec259469 | |||
22c8126cea | |||
5937a9d4f2 | |||
2db9c5f1d9 | |||
a18eda6748 | |||
c250c57a22 | |||
473a5294ea | |||
b2bc519e26 | |||
6f64f29177 | |||
a5619e2307 | |||
be5e457d1b | |||
3225ec5fe0 | |||
caa6d4da17 | |||
9e3590a39d | |||
5624fc3a74 | |||
64bd7e3642 |
17
.editorconfig
Normal file
17
.editorconfig
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# 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
|
8
.env
8
.env
@@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
###> symfony/framework-bundle ###
|
###> symfony/framework-bundle ###
|
||||||
APP_ENV=dev
|
APP_ENV=dev
|
||||||
APP_SECRET=a617c2ab616c5688ff5b0e95ad646641
|
APP_SECRET=
|
||||||
###< symfony/framework-bundle ###
|
###< symfony/framework-bundle ###
|
||||||
|
|
||||||
###> doctrine/doctrine-bundle ###
|
###> doctrine/doctrine-bundle ###
|
||||||
@@ -24,6 +24,10 @@ APP_SECRET=a617c2ab616c5688ff5b0e95ad646641
|
|||||||
# 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=mariadb-10.9.5&charset=utf8mb4"
|
# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=10.11.13-MariaDB&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 ###
|
||||||
|
|
||||||
|
###> nelmio/cors-bundle ###
|
||||||
|
CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$'
|
||||||
|
###< nelmio/cors-bundle ###
|
||||||
|
4
.env.dev
Normal file
4
.env.dev
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
|
||||||
|
###> symfony/framework-bundle ###
|
||||||
|
APP_SECRET=452d8323de922537717fb88b5fa6f80e
|
||||||
|
###< symfony/framework-bundle ###
|
2
.gitignore
vendored
2
.gitignore
vendored
@@ -8,4 +8,6 @@
|
|||||||
/var/
|
/var/
|
||||||
/vendor/
|
/vendor/
|
||||||
###< symfony/framework-bundle ###
|
###< symfony/framework-bundle ###
|
||||||
|
|
||||||
release.json
|
release.json
|
||||||
|
http-client.private.env.json
|
19
.http
Normal file
19
.http
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
### api me
|
||||||
|
GET {{host}}/api/me
|
||||||
|
Accept: application/json
|
||||||
|
X-AUTH-TOKEN: {{apiKey}}
|
||||||
|
|
||||||
|
### api snip get
|
||||||
|
GET {{host}}/api/snip/22
|
||||||
|
Accept: application/json
|
||||||
|
X-AUTH-TOKEN: {{apiKey}}
|
||||||
|
|
||||||
|
### api snip post edit
|
||||||
|
POST {{host}}/api/snip/22
|
||||||
|
Content-Type: application/json
|
||||||
|
X-AUTH-TOKEN: {{apiKey}}
|
||||||
|
|
||||||
|
{
|
||||||
|
"name": "new snip name",
|
||||||
|
"content": "new snip content"
|
||||||
|
}
|
@@ -4,6 +4,10 @@
|
|||||||
use App\Kernel;
|
use App\Kernel;
|
||||||
use Symfony\Bundle\FrameworkBundle\Console\Application;
|
use Symfony\Bundle\FrameworkBundle\Console\Application;
|
||||||
|
|
||||||
|
if (!is_dir(dirname(__DIR__).'/vendor')) {
|
||||||
|
throw new LogicException('Dependencies are missing. Try running "composer install".');
|
||||||
|
}
|
||||||
|
|
||||||
if (!is_file(dirname(__DIR__).'/vendor/autoload_runtime.php')) {
|
if (!is_file(dirname(__DIR__).'/vendor/autoload_runtime.php')) {
|
||||||
throw new LogicException('Symfony Runtime is missing. Try running "composer require symfony/runtime".');
|
throw new LogicException('Symfony Runtime is missing. Try running "composer require symfony/runtime".');
|
||||||
}
|
}
|
||||||
|
@@ -4,35 +4,44 @@
|
|||||||
"minimum-stability": "stable",
|
"minimum-stability": "stable",
|
||||||
"prefer-stable": true,
|
"prefer-stable": true,
|
||||||
"require": {
|
"require": {
|
||||||
"php": ">=8.3",
|
"php": ">=8.4",
|
||||||
"ext-ctype": "*",
|
"ext-ctype": "*",
|
||||||
"ext-iconv": "*",
|
"ext-iconv": "*",
|
||||||
"czproject/git-php": "^4.1",
|
|
||||||
"doctrine/doctrine-bundle": "^2.9",
|
"doctrine/doctrine-bundle": "^2.9",
|
||||||
"doctrine/doctrine-migrations-bundle": "^3.2",
|
"doctrine/doctrine-migrations-bundle": "^3.4",
|
||||||
"doctrine/orm": "^2.14",
|
"doctrine/orm": "^3.4",
|
||||||
|
"league/commonmark": "^2.6",
|
||||||
"league/pipeline": "^1.0",
|
"league/pipeline": "^1.0",
|
||||||
"symfony/console": "7.0.*",
|
"nelmio/cors-bundle": "^2.5",
|
||||||
"symfony/dotenv": "7.0.*",
|
"phpdocumentor/reflection-docblock": "^5.6",
|
||||||
|
"phpstan/phpdoc-parser": "^2.1",
|
||||||
|
"symfony/asset": "*",
|
||||||
|
"symfony/console": "*",
|
||||||
|
"symfony/dotenv": "*",
|
||||||
"symfony/flex": "^2",
|
"symfony/flex": "^2",
|
||||||
"symfony/form": "7.0.*",
|
"symfony/form": "*",
|
||||||
"symfony/framework-bundle": "7.0.*",
|
"symfony/framework-bundle": "*",
|
||||||
"symfony/monolog-bundle": "^3.0",
|
"symfony/monolog-bundle": "^3.0",
|
||||||
"symfony/runtime": "7.0.*",
|
"symfony/object-mapper": "7.3.*",
|
||||||
"symfony/security-bundle": "7.0.*",
|
"symfony/property-access": "*",
|
||||||
"symfony/twig-bundle": "7.0.*",
|
"symfony/property-info": "*",
|
||||||
"symfony/uid": "7.0.*",
|
"symfony/runtime": "*",
|
||||||
"symfony/validator": "7.0.*",
|
"symfony/security-bundle": "*",
|
||||||
"symfony/yaml": "7.0.*",
|
"symfony/serializer": "*",
|
||||||
|
"symfony/twig-bundle": "*",
|
||||||
|
"symfony/uid": "*",
|
||||||
|
"symfony/validator": "*",
|
||||||
|
"symfony/yaml": "*",
|
||||||
|
"tempest/highlight": "^2.11",
|
||||||
"twig/extra-bundle": "^2.12|^3.0",
|
"twig/extra-bundle": "^2.12|^3.0",
|
||||||
"twig/twig": "^2.12|^3.0"
|
"twig/twig": "^3.0"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"deployer/deployer": "^7.3",
|
"deployer/deployer": "^7.3",
|
||||||
"symfony/debug-bundle": "7.0.*",
|
"symfony/debug-bundle": "*",
|
||||||
"symfony/maker-bundle": "^1.48",
|
"symfony/maker-bundle": "^1.48",
|
||||||
"symfony/stopwatch": "7.0.*",
|
"symfony/stopwatch": "*",
|
||||||
"symfony/web-profiler-bundle": "7.0.*"
|
"symfony/web-profiler-bundle": "*"
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"allow-plugins": {
|
"allow-plugins": {
|
||||||
@@ -47,11 +56,6 @@
|
|||||||
"App\\": "src/"
|
"App\\": "src/"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"autoload-dev": {
|
|
||||||
"psr-4": {
|
|
||||||
"App\\Tests\\": "tests/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"replace": {
|
"replace": {
|
||||||
"symfony/polyfill-ctype": "*",
|
"symfony/polyfill-ctype": "*",
|
||||||
"symfony/polyfill-iconv": "*",
|
"symfony/polyfill-iconv": "*",
|
||||||
@@ -59,7 +63,10 @@
|
|||||||
"symfony/polyfill-php73": "*",
|
"symfony/polyfill-php73": "*",
|
||||||
"symfony/polyfill-php74": "*",
|
"symfony/polyfill-php74": "*",
|
||||||
"symfony/polyfill-php80": "*",
|
"symfony/polyfill-php80": "*",
|
||||||
"symfony/polyfill-php81": "*"
|
"symfony/polyfill-php81": "*",
|
||||||
|
"symfony/polyfill-php82": "*",
|
||||||
|
"symfony/polyfill-php83": "*",
|
||||||
|
"symfony/polyfill-php84": "*"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"auto-scripts": {
|
"auto-scripts": {
|
||||||
@@ -79,7 +86,7 @@
|
|||||||
"extra": {
|
"extra": {
|
||||||
"symfony": {
|
"symfony": {
|
||||||
"allow-contrib": false,
|
"allow-contrib": false,
|
||||||
"require": "7.0.*"
|
"require": "7.3.*"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
2880
composer.lock
generated
2880
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -11,4 +11,5 @@ return [
|
|||||||
Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true],
|
Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true],
|
||||||
Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true],
|
Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true],
|
||||||
Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true],
|
Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true],
|
||||||
|
Nelmio\CorsBundle\NelmioCorsBundle::class => ['all' => true],
|
||||||
];
|
];
|
||||||
|
11
config/packages/csrf.yaml
Normal file
11
config/packages/csrf.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Enable stateless CSRF protection for forms and logins/logouts
|
||||||
|
framework:
|
||||||
|
form:
|
||||||
|
csrf_protection:
|
||||||
|
token_id: submit
|
||||||
|
|
||||||
|
csrf_protection:
|
||||||
|
stateless_token_ids:
|
||||||
|
- submit
|
||||||
|
- authenticate
|
||||||
|
- logout
|
@@ -4,18 +4,28 @@ doctrine:
|
|||||||
|
|
||||||
# IMPORTANT: You MUST configure your server version,
|
# IMPORTANT: You MUST configure your server version,
|
||||||
# either here or in the DATABASE_URL env var (see .env file)
|
# either here or in the DATABASE_URL env var (see .env file)
|
||||||
#server_version: '15'
|
#server_version: '16'
|
||||||
|
|
||||||
|
profiling_collect_backtrace: '%kernel.debug%'
|
||||||
|
use_savepoints: true
|
||||||
orm:
|
orm:
|
||||||
|
report_fields_where_declared: true
|
||||||
auto_generate_proxy_classes: true
|
auto_generate_proxy_classes: true
|
||||||
enable_lazy_ghost_objects: true
|
enable_native_lazy_objects: 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:
|
||||||
|
Doctrine\DBAL\Platforms\PostgreSQLPlatform: identity
|
||||||
auto_mapping: true
|
auto_mapping: true
|
||||||
mappings:
|
mappings:
|
||||||
App:
|
App:
|
||||||
|
type: attribute
|
||||||
is_bundle: false
|
is_bundle: false
|
||||||
dir: '%kernel.project_dir%/src/Entity'
|
dir: '%kernel.project_dir%/src/Entity'
|
||||||
prefix: 'App\Entity'
|
prefix: 'App\Entity'
|
||||||
alias: App
|
alias: App
|
||||||
|
controller_resolver:
|
||||||
|
auto_mapping: false
|
||||||
|
|
||||||
when@test:
|
when@test:
|
||||||
doctrine:
|
doctrine:
|
||||||
|
@@ -1,22 +1,12 @@
|
|||||||
# see https://symfony.com/doc/current/reference/configuration/framework.html
|
# see https://symfony.com/doc/current/reference/configuration/framework.html
|
||||||
framework:
|
framework:
|
||||||
secret: '%env(APP_SECRET)%'
|
secret: '%env(APP_SECRET)%'
|
||||||
#csrf_protection: true
|
|
||||||
http_method_override: false
|
|
||||||
handle_all_throwables: true
|
|
||||||
|
|
||||||
# Enables session support. Note that the session will ONLY be started if you read or write from it.
|
# Note that the session will be started ONLY if you read or write from it.
|
||||||
# Remove or comment this section to explicitly disable session support.
|
session: true
|
||||||
session:
|
|
||||||
handler_id: null
|
|
||||||
cookie_secure: auto
|
|
||||||
cookie_samesite: lax
|
|
||||||
storage_factory_id: session.storage.factory.native
|
|
||||||
|
|
||||||
#esi: true
|
#esi: true
|
||||||
#fragments: true
|
#fragments: true
|
||||||
php_errors:
|
|
||||||
log: true
|
|
||||||
|
|
||||||
when@test:
|
when@test:
|
||||||
framework:
|
framework:
|
||||||
|
@@ -59,3 +59,4 @@ when@prod:
|
|||||||
type: stream
|
type: stream
|
||||||
channels: [deprecation]
|
channels: [deprecation]
|
||||||
path: php://stderr
|
path: php://stderr
|
||||||
|
formatter: monolog.formatter.json
|
||||||
|
15
config/packages/nelmio_cors.yaml
Normal file
15
config/packages/nelmio_cors.yaml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
nelmio_cors:
|
||||||
|
defaults:
|
||||||
|
origin_regex: true
|
||||||
|
allow_origin: ['%env(CORS_ALLOW_ORIGIN)%']
|
||||||
|
allow_methods: ['GET', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE']
|
||||||
|
allow_headers: ['Content-Type', 'Authorization']
|
||||||
|
expose_headers: ['Link']
|
||||||
|
max_age: 3600
|
||||||
|
paths:
|
||||||
|
'^/api/':
|
||||||
|
allow_origin: ['*']
|
||||||
|
allow_headers: ['*']
|
||||||
|
allow_methods: ['POST', 'PUT', 'GET', 'DELETE']
|
||||||
|
max_age: 3600
|
||||||
|
'^/': null
|
3
config/packages/property_info.yaml
Normal file
3
config/packages/property_info.yaml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
framework:
|
||||||
|
property_info:
|
||||||
|
with_constructor_extractor: true
|
@@ -1,7 +1,5 @@
|
|||||||
framework:
|
framework:
|
||||||
router:
|
router:
|
||||||
utf8: true
|
|
||||||
|
|
||||||
# Configure how to generate URLs in non-HTTP contexts, such as CLI commands.
|
# Configure how to generate URLs in non-HTTP contexts, such as CLI commands.
|
||||||
# See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
|
# See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
|
||||||
#default_uri: http://localhost
|
#default_uri: http://localhost
|
||||||
|
@@ -24,6 +24,8 @@ security:
|
|||||||
remember_me:
|
remember_me:
|
||||||
secret: '%kernel.secret%' # required
|
secret: '%kernel.secret%' # required
|
||||||
lifetime: 2419200 # 4 weeks in seconds
|
lifetime: 2419200 # 4 weeks in seconds
|
||||||
|
custom_authenticators:
|
||||||
|
- App\Security\TokenAuthenticator
|
||||||
|
|
||||||
secured_area:
|
secured_area:
|
||||||
form_login:
|
form_login:
|
||||||
@@ -39,11 +41,14 @@ security:
|
|||||||
# Note: Only the *first* access control that matches will be used
|
# Note: Only the *first* access control that matches will be used
|
||||||
access_control:
|
access_control:
|
||||||
- { path: ^/login$, role: PUBLIC_ACCESS }
|
- { path: ^/login$, role: PUBLIC_ACCESS }
|
||||||
- { path: ^/register, role: PUBLIC_ACCESS }
|
- { path: ^/register$, role: PUBLIC_ACCESS }
|
||||||
- { path: ^/logout$, role: ROLE_USER }
|
- { path: ^/logout$, role: ROLE_USER }
|
||||||
- { path: ^/admin, role: ROLE_ADMIN }
|
- { path: ^/admin, role: ROLE_ADMIN }
|
||||||
|
|
||||||
|
- { path: ^/$, role: PUBLIC_ACCESS }
|
||||||
|
- { path: ^/snip/single, role: PUBLIC_ACCESS }
|
||||||
- { path: ^/snip/raw, role: PUBLIC_ACCESS }
|
- { path: ^/snip/raw, role: PUBLIC_ACCESS }
|
||||||
|
- { path: ^/snip/public$, role: PUBLIC_ACCESS }
|
||||||
|
|
||||||
- { path: ^/, role: ROLE_USER }
|
- { path: ^/, role: ROLE_USER }
|
||||||
|
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
twig:
|
twig:
|
||||||
default_path: '%kernel.project_dir%/templates'
|
|
||||||
form_themes: ['bootstrap_5_layout.html.twig']
|
form_themes: ['bootstrap_5_layout.html.twig']
|
||||||
|
file_name_pattern: '*.twig'
|
||||||
|
|
||||||
when@test:
|
when@test:
|
||||||
twig:
|
twig:
|
||||||
|
@@ -1,4 +0,0 @@
|
|||||||
framework:
|
|
||||||
uid:
|
|
||||||
default_uuid_version: 7
|
|
||||||
time_based_uuid_version: 7
|
|
@@ -1,7 +1,5 @@
|
|||||||
framework:
|
framework:
|
||||||
validation:
|
validation:
|
||||||
email_validation_mode: html5
|
|
||||||
|
|
||||||
# Enables validator auto-mapping support.
|
# Enables validator auto-mapping support.
|
||||||
# For instance, basic validation constraints will be inferred from Doctrine's metadata.
|
# For instance, basic validation constraints will be inferred from Doctrine's metadata.
|
||||||
#auto_mapping:
|
#auto_mapping:
|
||||||
|
@@ -1,17 +1,13 @@
|
|||||||
when@dev:
|
when@dev:
|
||||||
web_profiler:
|
web_profiler:
|
||||||
toolbar: true
|
toolbar: true
|
||||||
intercept_redirects: false
|
|
||||||
|
|
||||||
framework:
|
framework:
|
||||||
profiler:
|
profiler:
|
||||||
only_exceptions: false
|
|
||||||
collect_serializer_data: true
|
collect_serializer_data: true
|
||||||
|
|
||||||
when@test:
|
when@test:
|
||||||
web_profiler:
|
|
||||||
toolbar: false
|
|
||||||
intercept_redirects: false
|
|
||||||
|
|
||||||
framework:
|
framework:
|
||||||
profiler: { collect: false }
|
profiler:
|
||||||
|
collect: false
|
||||||
|
collect_serializer_data: true
|
||||||
|
@@ -3,3 +3,12 @@ controllers:
|
|||||||
path: ../src/Controller/
|
path: ../src/Controller/
|
||||||
namespace: App\Controller
|
namespace: App\Controller
|
||||||
type: attribute
|
type: attribute
|
||||||
|
exclude: ../src/Controller/Api/
|
||||||
|
|
||||||
|
rest_controllers:
|
||||||
|
resource:
|
||||||
|
path: ../src/Controller/Api/
|
||||||
|
namespace: App\Controller\Api
|
||||||
|
type: attribute
|
||||||
|
prefix: /api
|
||||||
|
defaults: { _format: "json" }
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
when@dev:
|
when@dev:
|
||||||
_errors:
|
_errors:
|
||||||
resource: '@FrameworkBundle/Resources/config/routing/errors.xml'
|
resource: '@FrameworkBundle/Resources/config/routing/errors.php'
|
||||||
prefix: /_error
|
prefix: /_error
|
||||||
|
3
config/routes/security.yaml
Normal file
3
config/routes/security.yaml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
_security_logout:
|
||||||
|
resource: security.route_loader.logout
|
||||||
|
type: service
|
@@ -1,8 +1,8 @@
|
|||||||
when@dev:
|
when@dev:
|
||||||
web_profiler_wdt:
|
web_profiler_wdt:
|
||||||
resource: '@WebProfilerBundle/Resources/config/routing/wdt.xml'
|
resource: '@WebProfilerBundle/Resources/config/routing/wdt.php'
|
||||||
prefix: /_wdt
|
prefix: /_wdt
|
||||||
|
|
||||||
web_profiler_profiler:
|
web_profiler_profiler:
|
||||||
resource: '@WebProfilerBundle/Resources/config/routing/profiler.xml'
|
resource: '@WebProfilerBundle/Resources/config/routing/profiler.php'
|
||||||
prefix: /_profiler
|
prefix: /_profiler
|
||||||
|
@@ -4,8 +4,6 @@
|
|||||||
# Put parameters here that don't need to change on each machine where the app is deployed
|
# Put parameters here that don't need to change on each machine where the app is deployed
|
||||||
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
|
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
|
||||||
parameters:
|
parameters:
|
||||||
snipStorageType: 'db' # 'db' or 'git
|
|
||||||
gitStoragePath: '%kernel.project_dir%/var/snips'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# default configuration for services in *this* file
|
# default configuration for services in *this* file
|
||||||
@@ -17,16 +15,6 @@ 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:
|
|
||||||
- '../src/DependencyInjection/'
|
|
||||||
- '../src/Entity/'
|
|
||||||
- '../src/Kernel.php'
|
|
||||||
|
|
||||||
App\Service\LastRelease:
|
# add more service definitions when explicit configuration is needed
|
||||||
arguments:
|
# please note that last definitions always *replace* previous ones
|
||||||
- '%kernel.project_dir%/release.json'
|
|
||||||
|
|
||||||
App\Service\SnipServiceFactory:
|
|
||||||
arguments:
|
|
||||||
$gitStoragePath: '%gitStoragePath%'
|
|
||||||
$storageType: '%snipStorageType%'
|
|
||||||
|
103
deploy.php
103
deploy.php
@@ -2,107 +2,16 @@
|
|||||||
|
|
||||||
namespace Deployer;
|
namespace Deployer;
|
||||||
|
|
||||||
require_once 'recipe/common.php';
|
require_once 'deploy/symfony.php';
|
||||||
|
|
||||||
|
|
||||||
// Project name
|
|
||||||
set('application', 'snips');
|
set('application', 'snips');
|
||||||
|
|
||||||
// Project repository
|
|
||||||
set('repository', 'git@git.loken.nl:ardent/Snips.git');
|
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
|
|
||||||
set('shared_dirs', ['var/log', 'var/sessions', 'var/snips']);
|
|
||||||
set('shared_files', ['.env.local']);
|
|
||||||
//set('writable_dirs', ['var']);
|
|
||||||
set('migrations_config', '');
|
|
||||||
set('allow_anonymous_stats', false);
|
|
||||||
|
|
||||||
// Hosts
|
|
||||||
host('snips.loken.nl')
|
host('snips.loken.nl')
|
||||||
->setRemoteUser('www-data')
|
->setRemoteUser('tim')
|
||||||
->set('branch', function () {
|
->set('branch', function () {
|
||||||
return input()->getOption('branch') ?: 'main';
|
return input()->getOption('branch') ?: 'master';
|
||||||
})
|
})
|
||||||
->set('deploy_path', '~/snips.loken.nl');
|
->set('deploy_path', '/var/www/snips.loken.nl')
|
||||||
|
;
|
||||||
set('bin/console', function () {
|
|
||||||
return parse('{{release_path}}/bin/console');
|
|
||||||
});
|
|
||||||
|
|
||||||
set('console_options', function () {
|
|
||||||
return '--no-interaction';
|
|
||||||
});
|
|
||||||
|
|
||||||
desc('Clear cache');
|
|
||||||
task('cache:clear', function () {
|
|
||||||
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}}');
|
|
||||||
});
|
|
||||||
|
|
||||||
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');
|
|
||||||
});
|
|
||||||
|
|
||||||
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/AnimeRSS4';
|
|
||||||
$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('Deploy project');
|
|
||||||
//task('deploy', [
|
|
||||||
// 'deployment:log',
|
|
||||||
//]);
|
|
||||||
|
|
||||||
desc('Deploy project');
|
|
||||||
task('deploy', [
|
|
||||||
'deploy:prepare',
|
|
||||||
'deploy:vendors',
|
|
||||||
'cache:clear',
|
|
||||||
'cache:warmup',
|
|
||||||
'database:migrate',
|
|
||||||
'deployment:log',
|
|
||||||
'deploy:symlink',
|
|
||||||
'deploy:unlock',
|
|
||||||
'deploy:cleanup',
|
|
||||||
]);
|
|
||||||
|
|
||||||
after('deploy', 'deploy:success');
|
|
||||||
|
|
||||||
// [Optional] if deploy fails automatically unlock.
|
|
||||||
after('deploy:failed', 'deploy:unlock');
|
|
||||||
|
27
deploy/git.php
Normal file
27
deploy/git.php
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Deployer;
|
||||||
|
|
||||||
|
desc('Transfers information about current git commit to server');
|
||||||
|
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);
|
||||||
|
$array = [
|
||||||
|
'branch' => $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');
|
||||||
|
});
|
65
deploy/symfony.php
Normal file
65
deploy/symfony.php
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Deployer;
|
||||||
|
|
||||||
|
require_once 'recipe/common.php';
|
||||||
|
require_once 'deploy/git.php';
|
||||||
|
|
||||||
|
// [Optional] Allocate tty for git clone. Default value is false.
|
||||||
|
set('git_tty', true);
|
||||||
|
|
||||||
|
// Shared files/dirs between deploys
|
||||||
|
set('shared_dirs', ['var/log', 'var/sessions']);
|
||||||
|
set('shared_files', ['.env.local']);
|
||||||
|
|
||||||
|
set('writable_dirs', ['.']);
|
||||||
|
set('writable_mode', 'sticky');
|
||||||
|
set('http-group', 'www-data');
|
||||||
|
|
||||||
|
set('migrations_config', '');
|
||||||
|
set('allow_anonymous_stats', false);
|
||||||
|
|
||||||
|
set('console_options', fn() => '--no-interaction');
|
||||||
|
set('bin/console', fn() => parse('{{release_path}}/bin/console'));
|
||||||
|
|
||||||
|
set('composer_options', '--verbose --prefer-dist --no-progress --no-interaction --no-dev --optimize-autoloader --no-scripts');
|
||||||
|
|
||||||
|
desc('Clear cache');
|
||||||
|
task('cache:clear', fn() => run('{{bin/php}} {{bin/console}} cache:clear {{console_options}} --no-warmup'));
|
||||||
|
|
||||||
|
desc('Warm up cache');
|
||||||
|
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));
|
||||||
|
});
|
||||||
|
|
||||||
|
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', [
|
||||||
|
'deploy:prepare',
|
||||||
|
'deploy:vendors',
|
||||||
|
'database:migrate',
|
||||||
|
'cache:clear',
|
||||||
|
'cache:warmup',
|
||||||
|
'deployment:log',
|
||||||
|
'deploy:symlink',
|
||||||
|
'deploy:unlock',
|
||||||
|
'deploy:cleanup',
|
||||||
|
'deploy:current',
|
||||||
|
]);
|
||||||
|
|
||||||
|
after('deploy', 'deploy:success');
|
||||||
|
|
||||||
|
after('deploy:failed', 'deploy:unlock');
|
5
http-client.env.json
Normal file
5
http-client.env.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"dev": {
|
||||||
|
"host": "https://snips.localhost"
|
||||||
|
}
|
||||||
|
}
|
35
migrations/Version20231220204107.php
Normal file
35
migrations/Version20231220204107.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20231220204107 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this up() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('ALTER TABLE snip ADD active_version_id BINARY(16) DEFAULT NULL COMMENT \'(DC2Type:ulid)\'');
|
||||||
|
$this->addSql('ALTER TABLE snip ADD CONSTRAINT FK_FEBD97966A1E45F3 FOREIGN KEY (active_version_id) REFERENCES snip_content (id)');
|
||||||
|
$this->addSql('CREATE INDEX IDX_FEBD97966A1E45F3 ON snip (active_version_id)');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this down() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('ALTER TABLE snip DROP FOREIGN KEY FK_FEBD97966A1E45F3');
|
||||||
|
$this->addSql('DROP INDEX IDX_FEBD97966A1E45F3 ON snip');
|
||||||
|
$this->addSql('ALTER TABLE snip DROP active_version_id');
|
||||||
|
}
|
||||||
|
}
|
35
migrations/Version20250414192457.php
Normal file
35
migrations/Version20250414192457.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20250414192457 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this up() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
ALTER TABLE snip_content ADD diff JSON DEFAULT NULL COMMENT '(DC2Type:json)'
|
||||||
|
SQL);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this down() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
ALTER TABLE snip_content DROP diff
|
||||||
|
SQL);
|
||||||
|
}
|
||||||
|
}
|
38
migrations/Version20250422222542.php
Normal file
38
migrations/Version20250422222542.php
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20250422222542 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this up() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
ALTER TABLE snip ADD parser VARCHAR(255) NOT NULL
|
||||||
|
SQL);
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
UPDATE snip SET parser = 'generic'
|
||||||
|
SQL);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this down() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
ALTER TABLE snip DROP parser
|
||||||
|
SQL);
|
||||||
|
}
|
||||||
|
}
|
38
migrations/Version20250425182334.php
Normal file
38
migrations/Version20250425182334.php
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20250425182334 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this up() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
ALTER TABLE snip ADD visible TINYINT(1) NOT NULL
|
||||||
|
SQL);
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
UPDATE snip SET visible = 1
|
||||||
|
SQL);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this down() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
ALTER TABLE snip DROP visible
|
||||||
|
SQL);
|
||||||
|
}
|
||||||
|
}
|
35
migrations/Version20250427184240.php
Normal file
35
migrations/Version20250427184240.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20250427184240 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this up() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
ALTER TABLE user ADD api_key VARCHAR(255) DEFAULT NULL
|
||||||
|
SQL);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this down() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
ALTER TABLE `user` DROP api_key
|
||||||
|
SQL);
|
||||||
|
}
|
||||||
|
}
|
44
migrations/Version20250510142748.php
Normal file
44
migrations/Version20250510142748.php
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20250510142748 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this up() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
ALTER TABLE snip DROP INDEX IDX_FEBD97966A1E45F3, ADD UNIQUE INDEX UNIQ_FEBD97966A1E45F3 (active_version_id)
|
||||||
|
SQL);
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
ALTER TABLE snip ADD archived TINYINT(1) NOT NULL
|
||||||
|
SQL);
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
UPDATE snip SET archived = 0
|
||||||
|
SQL);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this down() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
ALTER TABLE snip DROP INDEX UNIQ_FEBD97966A1E45F3, ADD INDEX IDX_FEBD97966A1E45F3 (active_version_id)
|
||||||
|
SQL);
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
ALTER TABLE snip DROP archived
|
||||||
|
SQL);
|
||||||
|
}
|
||||||
|
}
|
59
migrations/Version20250510180413.php
Normal file
59
migrations/Version20250510180413.php
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20250510180413 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this up() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
CREATE TABLE tag (id INT AUTO_INCREMENT NOT NULL, user_id INT NOT NULL, name VARCHAR(255) NOT NULL, INDEX IDX_389B783A76ED395 (user_id), UNIQUE INDEX user_tag_unique (name, user_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB
|
||||||
|
SQL);
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
CREATE TABLE tag_snip (tag_id INT NOT NULL, snip_id INT NOT NULL, INDEX IDX_10B22820BAD26311 (tag_id), INDEX IDX_10B22820140FD260 (snip_id), PRIMARY KEY(tag_id, snip_id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB
|
||||||
|
SQL);
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
ALTER TABLE tag ADD CONSTRAINT FK_389B783A76ED395 FOREIGN KEY (user_id) REFERENCES `user` (id)
|
||||||
|
SQL);
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
ALTER TABLE tag_snip ADD CONSTRAINT FK_10B22820BAD26311 FOREIGN KEY (tag_id) REFERENCES tag (id) ON DELETE CASCADE
|
||||||
|
SQL);
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
ALTER TABLE tag_snip ADD CONSTRAINT FK_10B22820140FD260 FOREIGN KEY (snip_id) REFERENCES snip (id) ON DELETE CASCADE
|
||||||
|
SQL);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this down() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
ALTER TABLE tag DROP FOREIGN KEY FK_389B783A76ED395
|
||||||
|
SQL);
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
ALTER TABLE tag_snip DROP FOREIGN KEY FK_10B22820BAD26311
|
||||||
|
SQL);
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
ALTER TABLE tag_snip DROP FOREIGN KEY FK_10B22820140FD260
|
||||||
|
SQL);
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
DROP TABLE tag
|
||||||
|
SQL);
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
DROP TABLE tag_snip
|
||||||
|
SQL);
|
||||||
|
}
|
||||||
|
}
|
35
migrations/Version20250513103236.php
Normal file
35
migrations/Version20250513103236.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20250513103236 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this up() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
ALTER TABLE snip_content ADD name VARCHAR(255) DEFAULT NULL
|
||||||
|
SQL);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this down() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
ALTER TABLE snip_content DROP name
|
||||||
|
SQL);
|
||||||
|
}
|
||||||
|
}
|
87
public/github-light-default.css
Normal file
87
public/github-light-default.css
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
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;
|
||||||
|
}
|
BIN
public/snips.png
Normal file
BIN
public/snips.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.2 MiB |
44
src/Command/SnipUpdateContentCommand.php
Normal file
44
src/Command/SnipUpdateContentCommand.php
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Command;
|
||||||
|
|
||||||
|
use App\Entity\SnipContent;
|
||||||
|
use App\Repository\SnipContentRepository;
|
||||||
|
use App\Service\SnipContent\Lexer;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'app:snip:update-content',
|
||||||
|
description: 'Update Snip content line endings',
|
||||||
|
)]
|
||||||
|
class SnipUpdateContentCommand extends Command
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly SnipContentRepository $snipContentRepository,
|
||||||
|
)
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
// $io = new SymfonyStyle($input, $output);
|
||||||
|
|
||||||
|
$qb = $this->snipContentRepository->createQueryBuilder('s');
|
||||||
|
$qb->where('s.text IS NOT NULL');
|
||||||
|
|
||||||
|
/** @var SnipContent $snipContent */
|
||||||
|
foreach ($qb->getQuery()->getResult() as $snipContent) {
|
||||||
|
$text = $snipContent->text;
|
||||||
|
$text = Lexer::reconstruct(Lexer::tokenize($text));
|
||||||
|
$snipContent->text = $text;
|
||||||
|
$this->snipContentRepository->save($snipContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
39
src/Controller/Api/AbstractApiController.php
Normal file
39
src/Controller/Api/AbstractApiController.php
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controller\Api;
|
||||||
|
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class AbstractApiController extends AbstractController
|
||||||
|
{
|
||||||
|
private function apiResponse(bool $success, array|NormalizableInterface $data): Response
|
||||||
|
{
|
||||||
|
if ($data instanceof NormalizableInterface) {
|
||||||
|
$data = $data->normalize();
|
||||||
|
}
|
||||||
|
if (is_array($data)) {
|
||||||
|
foreach ($data as $key => $value) {
|
||||||
|
if ($value instanceof NormalizableInterface) {
|
||||||
|
$data[$key] = $value->normalize();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new JsonResponse([
|
||||||
|
'success' => $success,
|
||||||
|
'data' => $data,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
final protected function errorResponse(string $message): Response
|
||||||
|
{
|
||||||
|
return $this->apiResponse(false, ['message' => $message]);
|
||||||
|
}
|
||||||
|
|
||||||
|
final protected function successResponse(array|NormalizableInterface $data = []): Response
|
||||||
|
{
|
||||||
|
return $this->apiResponse(true, $data);
|
||||||
|
}
|
||||||
|
}
|
78
src/Controller/Api/ApiController.php
Normal file
78
src/Controller/Api/ApiController.php
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controller\Api;
|
||||||
|
|
||||||
|
use App\Dto\SnipPostRequest;
|
||||||
|
use App\Entity\Snip;
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\Repository\SnipRepository;
|
||||||
|
use App\Security\Voter\SnipVoter;
|
||||||
|
use App\Service\SnipContent\SnipContentService;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
|
||||||
|
use Symfony\Component\ObjectMapper\ObjectMapperInterface;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
|
class ApiController extends AbstractApiController
|
||||||
|
{
|
||||||
|
#[Route('/me', methods: ['GET'])]
|
||||||
|
public function me(): Response
|
||||||
|
{
|
||||||
|
/** @var User $user */
|
||||||
|
$user = $this->getUser();
|
||||||
|
|
||||||
|
return $this->successResponse([
|
||||||
|
'id' => $user->getId(),
|
||||||
|
'name' => $user->getName(),
|
||||||
|
'email' => $user->getEmail(),
|
||||||
|
'apiKey' => $user->getApiKey(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/snip/{snip}', methods: ['GET'])]
|
||||||
|
public function getSnip(Snip $snip): Response
|
||||||
|
{
|
||||||
|
$this->denyAccessUnlessGranted(SnipVoter::VIEW, $snip);
|
||||||
|
|
||||||
|
return $this->successResponse([
|
||||||
|
'id' => $snip->id,
|
||||||
|
'content' => $snip->getActiveText(),
|
||||||
|
'createdBy' => [
|
||||||
|
'id' => $snip->createdBy->getId(),
|
||||||
|
'name' => $snip->createdBy->getName(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/snip/{snip}', methods: ['POST'])]
|
||||||
|
public function postSnip(
|
||||||
|
Snip $snip,
|
||||||
|
#[MapRequestPayload] SnipPostRequest $request,
|
||||||
|
SnipContentService $cs,
|
||||||
|
SnipRepository $repo,
|
||||||
|
ObjectMapperInterface $mapper,
|
||||||
|
): Response
|
||||||
|
{
|
||||||
|
$this->denyAccessUnlessGranted(SnipVoter::EDIT, $snip);
|
||||||
|
|
||||||
|
if (!($snip->activeVersion === $snip->getLatestVersion())) {
|
||||||
|
return $this->errorResponse('Snip is not the latest version');
|
||||||
|
}
|
||||||
|
|
||||||
|
$mapper->map($request, $snip);
|
||||||
|
$repo->save($snip);
|
||||||
|
if ($request->content !== null) {
|
||||||
|
$cs->update($snip, $request->content, $request->contentName);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->successResponse([
|
||||||
|
'id' => $snip->id,
|
||||||
|
'name' => $snip->name,
|
||||||
|
'content' => $snip->getActiveText(),
|
||||||
|
'createdBy' => [
|
||||||
|
'id' => $snip->createdBy->getId(),
|
||||||
|
'name' => $snip->createdBy->getName(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
42
src/Controller/Api/ExtensionController.php
Normal file
42
src/Controller/Api/ExtensionController.php
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controller\Api;
|
||||||
|
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||||
|
use Symfony\Contracts\Cache\CacheInterface;
|
||||||
|
|
||||||
|
class ExtensionController extends AbstractApiController
|
||||||
|
{
|
||||||
|
#[Route('/content', methods: ['POST'])]
|
||||||
|
public function setContent(
|
||||||
|
Request $request,
|
||||||
|
CacheInterface $cache,
|
||||||
|
): Response {
|
||||||
|
// Parse JSON payload
|
||||||
|
$data = json_decode($request->getContent(), true);
|
||||||
|
if (!$data || !isset($data['html'])) {
|
||||||
|
return $this->errorResponse('Invalid JSON payload: Missing html parameter');
|
||||||
|
}
|
||||||
|
$html = $data['html'];
|
||||||
|
|
||||||
|
// Store HTML in cache with unique key
|
||||||
|
$userId = $this->getUser()?->getId();
|
||||||
|
$cacheKey = 'html_' . $userId . '_' . bin2hex(random_bytes(8));
|
||||||
|
$cache->get($cacheKey, function () use ($html) {
|
||||||
|
// value to cache
|
||||||
|
return $html;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build URL to redirect user to a form page later
|
||||||
|
$formUrl = $this->generateUrl(
|
||||||
|
'snip_new',
|
||||||
|
['cacheKey' => $cacheKey],
|
||||||
|
UrlGeneratorInterface::ABSOLUTE_URL
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->successResponse(['url' => $formUrl]);
|
||||||
|
}
|
||||||
|
}
|
8
src/Controller/Api/NormalizableInterface.php
Normal file
8
src/Controller/Api/NormalizableInterface.php
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controller\Api;
|
||||||
|
|
||||||
|
interface NormalizableInterface
|
||||||
|
{
|
||||||
|
public function normalize(): array;
|
||||||
|
}
|
14
src/Controller/Attribute/MapQueryCached.php
Normal file
14
src/Controller/Attribute/MapQueryCached.php
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controller\Attribute;
|
||||||
|
|
||||||
|
use App\Service\RequestDtoCache;
|
||||||
|
use Symfony\Component\HttpKernel\Attribute\MapQueryString;
|
||||||
|
|
||||||
|
#[\Attribute(\Attribute::TARGET_PARAMETER)]
|
||||||
|
class MapQueryCached extends MapQueryString
|
||||||
|
{
|
||||||
|
public function __construct() {
|
||||||
|
return parent::__construct(resolver: RequestDtoCache::class);
|
||||||
|
}
|
||||||
|
}
|
@@ -4,13 +4,17 @@ namespace App\Controller;
|
|||||||
|
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use Symfony\Component\Routing\Annotation\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
class HomeController extends AbstractController
|
class HomeController extends AbstractController
|
||||||
{
|
{
|
||||||
#[Route('/', name: 'home')]
|
#[Route('/', name: 'home')]
|
||||||
public function home(): Response
|
public function home(): Response
|
||||||
{
|
{
|
||||||
|
if ($this->getUser()) {
|
||||||
return $this->redirectToRoute('snip_index');
|
return $this->redirectToRoute('snip_index');
|
||||||
|
} else {
|
||||||
|
return $this->redirectToRoute('snip_public');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
@@ -11,7 +11,7 @@ use Symfony\Bundle\SecurityBundle\Security;
|
|||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||||
use Symfony\Component\Routing\Annotation\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
|
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
|
||||||
|
|
||||||
class SecurityController extends AbstractController
|
class SecurityController extends AbstractController
|
||||||
|
37
src/Controller/SnipContentController.php
Normal file
37
src/Controller/SnipContentController.php
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<?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() {}
|
||||||
|
|
||||||
|
#[Route('/compare/{to}/{from}', name: '_compare')]
|
||||||
|
public function compare(SnipContent $to, ?SnipContent $from = null): Response
|
||||||
|
{
|
||||||
|
$this->denyAccessUnlessGranted(SnipVoter::VIEW, $to->snip);
|
||||||
|
|
||||||
|
if ($from === null) {
|
||||||
|
$from = $to->parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
$diff = MyersDiff::buildDiffLines(
|
||||||
|
SnipContentService::rebuildText($from),
|
||||||
|
SnipContentService::rebuildText($to),
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->render('content/compare.html.twig', [
|
||||||
|
'snip' => $to->snip,
|
||||||
|
'diff' => $diff,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
@@ -2,68 +2,66 @@
|
|||||||
|
|
||||||
namespace App\Controller;
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Controller\Attribute\MapQueryCached;
|
||||||
|
use App\Dto\SnipFilterRequest;
|
||||||
use App\Entity\Snip;
|
use App\Entity\Snip;
|
||||||
|
use App\Entity\SnipContent;
|
||||||
|
use App\Entity\Tag;
|
||||||
use App\Form\ConfirmationType;
|
use App\Form\ConfirmationType;
|
||||||
use App\Form\SnipType;
|
use App\Form\SnipType;
|
||||||
use App\Repository\SnipRepository;
|
use App\Repository\SnipRepository;
|
||||||
use App\Security\Voter\SnipVoter;
|
use App\Security\Voter\SnipVoter;
|
||||||
use App\Service\SnipParser\Pipeline;
|
use App\Service\SnipContent\SnipContentService;
|
||||||
use App\Service\SnipServiceFactory;
|
use App\Service\SnipParser\ParserFactory;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use Symfony\Component\Routing\Annotation\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
use Symfony\Contracts\Cache\CacheInterface;
|
||||||
|
|
||||||
#[Route('/snip', name: 'snip')]
|
#[Route('/snip', name: 'snip')]
|
||||||
class SnipController extends AbstractController
|
class SnipController extends AbstractController
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly SnipRepository $repository,
|
private readonly SnipRepository $repository,
|
||||||
private readonly SnipServiceFactory $snipServiceFactory,
|
) {}
|
||||||
)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Route('/', name: '_index')]
|
#[Route('/', name: '_index')]
|
||||||
public function index(): Response
|
public function index(#[MapQueryCached] SnipFilterRequest $request): Response
|
||||||
{
|
{
|
||||||
return $this->render('snip/index.html.twig', [
|
return $this->render('snip/index.html.twig', [
|
||||||
'snips' => $this->repository->findByUser($this->getUser()),
|
'snips' => $this->repository->findByRequest($this->getUser(), $request),
|
||||||
'title' => 'My Snips',
|
'request' => $request,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Route('/public', name: '_public')]
|
#[Route('/public', name: '_public')]
|
||||||
public function public(): Response
|
public function public(): Response
|
||||||
{
|
{
|
||||||
return $this->render('snip/index.html.twig', [
|
return $this->render('snip/public.html.twig', [
|
||||||
'snips' => $this->repository->findPublic($this->getUser()),
|
'snips' => $this->repository->findPublic(),
|
||||||
'title' => 'Public Snips',
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Route('/single/{snip}', name: '_single')]
|
#[Route('/single/{snip}', name: '_single')]
|
||||||
public function single(Snip $snip, Pipeline $pl): Response
|
public function single(Snip $snip, ParserFactory $pf): Response
|
||||||
{
|
{
|
||||||
$this->denyAccessUnlessGranted(SnipVoter::VIEW, $snip);
|
$this->denyAccessUnlessGranted(SnipVoter::VIEW, $snip);
|
||||||
|
|
||||||
$snipService = $this->snipServiceFactory->create($snip);
|
|
||||||
dump($snipService);
|
|
||||||
return $this->render('snip/single.html.twig', [
|
return $this->render('snip/single.html.twig', [
|
||||||
'snip' => $snip,
|
'snip' => $snip,
|
||||||
'content' => $pl->parse($snipService->get()),
|
'content' => $pf->getBySnip($snip)->parseView($snip->getActiveText()),
|
||||||
'branch' => $snipService->getCommit(),
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Route('/raw/{snip}', name: '_raw')]
|
#[Route('/raw/{snip}', name: '_raw')]
|
||||||
public function raw(Snip $snip, Pipeline $pl, Request $request): Response
|
public function raw(Snip $snip, ParserFactory $pf, Request $request): Response
|
||||||
{
|
{
|
||||||
$this->denyAccessUnlessGranted(SnipVoter::VIEW, $snip);
|
$this->denyAccessUnlessGranted(SnipVoter::VIEW, $snip);
|
||||||
|
|
||||||
$response = new Response(
|
$response = new Response(
|
||||||
$pl->clean($this->snipServiceFactory->create($snip)->get()),
|
$pf->getBySnip($snip)->parseRaw($snip->getActiveText()),
|
||||||
Response::HTTP_OK,
|
Response::HTTP_OK,
|
||||||
['Content-Type' => 'text/html']
|
['Content-Type' => 'text/html']
|
||||||
);
|
);
|
||||||
@@ -71,7 +69,8 @@ class SnipController extends AbstractController
|
|||||||
->setVary(['Accept', 'Accept-Encoding'])
|
->setVary(['Accept', 'Accept-Encoding'])
|
||||||
->setEtag(md5($response->getContent()))
|
->setEtag(md5($response->getContent()))
|
||||||
->setTtl(3600)
|
->setTtl(3600)
|
||||||
->setClientTtl(300);
|
->setClientTtl(300)
|
||||||
|
;
|
||||||
|
|
||||||
if (!$request->isNoCache()) {
|
if (!$request->isNoCache()) {
|
||||||
$response->isNotModified($request);
|
$response->isNotModified($request);
|
||||||
@@ -81,25 +80,41 @@ class SnipController extends AbstractController
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Route('/edit/{snip}', name: '_edit')]
|
#[Route('/edit/{snip}', name: '_edit')]
|
||||||
public function edit(Snip $snip, Request $request): Response
|
public function edit(Snip $snip, Request $request, SnipContentService $contentService): Response
|
||||||
{
|
{
|
||||||
$this->denyAccessUnlessGranted(SnipVoter::EDIT, $snip);
|
$this->denyAccessUnlessGranted(SnipVoter::EDIT, $snip);
|
||||||
|
|
||||||
$form = $this->createForm(SnipType::class, $snip);
|
/**
|
||||||
$form->add('Save', SubmitType::class);
|
* Temporary solution to prevent editing of old versions
|
||||||
if ($snip->getId()) {
|
* It technically fully works, but rendering the version history needs an update first
|
||||||
$form->get('content')->setData($this->snipServiceFactory->create($snip)->get());
|
*/
|
||||||
|
$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);
|
||||||
$this->snipServiceFactory->create($snip)->update($form->get('content')->getData());
|
$contentService->update(
|
||||||
|
$snip,
|
||||||
|
$form->get('content')->getData(),
|
||||||
|
$form->get('contentName')->getData()
|
||||||
|
);
|
||||||
|
|
||||||
$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,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,14 +124,47 @@ class SnipController extends AbstractController
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Route('/new', name: '_new')]
|
#[Route('/new/{cacheKey}', name: '_new')]
|
||||||
public function new(Request $request): Response
|
public function new(
|
||||||
|
Request $request,
|
||||||
|
SnipContentService $contentService,
|
||||||
|
CacheInterface $cache,
|
||||||
|
?string $cacheKey = null,
|
||||||
|
): Response
|
||||||
{
|
{
|
||||||
$snip = new Snip();
|
$snip = new Snip();
|
||||||
$snip->setCreatedAtTodayNoSeconds()
|
$snip->setCreatedAtNow();
|
||||||
->setCreatedBy($this->getUser());
|
$snip->createdBy = $this->getUser();
|
||||||
|
|
||||||
return $this->edit($snip, $request);
|
$form = $this->createForm(SnipType::class, $snip);
|
||||||
|
$form->add('Create', SubmitType::class);
|
||||||
|
|
||||||
|
if ($cacheKey) {
|
||||||
|
$content = $cache->get($cacheKey, fn() => null);
|
||||||
|
$form->get('content')->setData($content);
|
||||||
|
$form->get('parser')->setData('unsafe');
|
||||||
|
}
|
||||||
|
|
||||||
|
$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')]
|
||||||
@@ -127,15 +175,31 @@ 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()) {
|
||||||
$this->snipServiceFactory->create($snip)->delete();
|
$snip->activeVersion = null;
|
||||||
|
$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));
|
||||||
return $this->redirectToRoute('snip_index');
|
return $this->redirectToRoute('snip_index');
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->render('form.html.twig', [
|
return $this->render('generic/form.html.twig', [
|
||||||
'message' => sprintf('Do you really want to delete "%s"?', $snip),
|
'message' => sprintf('Do you really want to delete "%s"?', $snip),
|
||||||
'form' => $form->createView(),
|
'form' => $form->createView(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Route('/archive/{snip}', name: '_archive')]
|
||||||
|
public function archive(Snip $snip): Response
|
||||||
|
{
|
||||||
|
$this->denyAccessUnlessGranted(SnipVoter::EDIT, $snip);
|
||||||
|
$snip->archived = !$snip->archived;
|
||||||
|
$this->repository->save($snip);
|
||||||
|
if ($snip->archived) {
|
||||||
|
$this->addFlash('success', sprintf('Snip "%s" archived', $snip));
|
||||||
|
} else {
|
||||||
|
$this->addFlash('success', sprintf('Snip "%s" unarchived', $snip));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->redirectToRoute('snip_edit', ['snip' => $snip->id]);
|
||||||
|
}
|
||||||
}
|
}
|
@@ -4,24 +4,21 @@ namespace App\Controller;
|
|||||||
|
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
use App\Form\ProfileType;
|
use App\Form\ProfileType;
|
||||||
use App\Form\UserSettingsType;
|
|
||||||
use App\Service\LastRelease;
|
use App\Service\LastRelease;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||||
use Symfony\Component\Routing\Annotation\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
use Symfony\Component\Uid\Uuid;
|
use Symfony\Component\Uid\Uuid;
|
||||||
|
|
||||||
#[Route('/user', name: 'user')]
|
#[Route('/user', name: 'user')]
|
||||||
class UserController extends AbstractController
|
class UserController extends AbstractController
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private EntityManagerInterface $em,
|
private readonly EntityManagerInterface $em,
|
||||||
)
|
) {}
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Route('/profile', name: '_profile')]
|
#[Route('/profile', name: '_profile')]
|
||||||
public function profile(
|
public function profile(
|
||||||
@@ -46,7 +43,8 @@ class UserController extends AbstractController
|
|||||||
$user,
|
$user,
|
||||||
$form->get('plainPassword')->getData()
|
$form->get('plainPassword')->getData()
|
||||||
)
|
)
|
||||||
);
|
)
|
||||||
|
;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$this->addFlash('success', 'Profile updated successfully');
|
$this->addFlash('success', 'Profile updated successfully');
|
||||||
@@ -60,4 +58,20 @@ class UserController extends AbstractController
|
|||||||
'release' => $lastRelease,
|
'release' => $lastRelease,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Route('/apikey/generate', name: '_apikey_generate')]
|
||||||
|
public function apiKeyGenerate(): Response
|
||||||
|
{
|
||||||
|
/** @var User $user */
|
||||||
|
$user = $this->getUser();
|
||||||
|
|
||||||
|
$apiKey = Uuid::v4()->toBase58();
|
||||||
|
$user->setApiKey($apiKey);
|
||||||
|
$this->em->persist($user);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
$this->addFlash('success', sprintf('Successfully generated new api key: "%s"', $apiKey));
|
||||||
|
|
||||||
|
return $this->redirectToRoute('user_profile');
|
||||||
|
}
|
||||||
}
|
}
|
@@ -3,17 +3,18 @@
|
|||||||
namespace App\Controller;
|
namespace App\Controller;
|
||||||
|
|
||||||
use App\Entity\Snip;
|
use App\Entity\Snip;
|
||||||
|
use App\Entity\SnipContent;
|
||||||
use App\Security\Voter\SnipVoter;
|
use App\Security\Voter\SnipVoter;
|
||||||
use App\Service\SnipServiceFactory;
|
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;
|
||||||
use Symfony\Component\Routing\Annotation\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
#[Route('/history/{snip}', name: 'history')]
|
#[Route('/version/{snip}', name: 'version')]
|
||||||
class HistoryController extends AbstractController
|
class VersionController extends AbstractController
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly SnipServiceFactory $snipServiceFactory,
|
private readonly SnipContentService $contentService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
#[Route('/', name: '_index')]
|
#[Route('/', name: '_index')]
|
||||||
@@ -21,21 +22,18 @@ class HistoryController extends AbstractController
|
|||||||
{
|
{
|
||||||
$this->denyAccessUnlessGranted(SnipVoter::EDIT, $snip);
|
$this->denyAccessUnlessGranted(SnipVoter::EDIT, $snip);
|
||||||
|
|
||||||
$snipService = $this->snipServiceFactory->create($snip);
|
return $this->render('version/index.html.twig', [
|
||||||
return $this->render('history/index.html.twig', [
|
|
||||||
'snip' => $snip,
|
'snip' => $snip,
|
||||||
'versions' => $snipService->getVersions(),
|
|
||||||
'latestVersion' => $snipService->getLatestVersion(),
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Route('/set/{version}', name: '_set')]
|
#[Route('/set/{version}', name: '_set')]
|
||||||
public function set(Snip $snip, string $version): Response
|
public function set(Snip $snip, SnipContent $version): Response
|
||||||
{
|
{
|
||||||
$this->denyAccessUnlessGranted(SnipVoter::EDIT, $snip);
|
$this->denyAccessUnlessGranted(SnipVoter::EDIT, $snip);
|
||||||
|
|
||||||
$this->snipServiceFactory->create($snip)->setVersion($version);
|
$this->contentService->setVersion($snip, $version);
|
||||||
$this->addFlash('success', 'Snip version set to ' . $version);
|
$this->addFlash('success', 'Snip version set to ' . $version->id);
|
||||||
return $this->redirectToRoute('snip_single', ['snip' => $snip->getId()]);
|
return $this->redirectToRoute('snip_single', ['snip' => $snip->id]);
|
||||||
}
|
}
|
||||||
}
|
}
|
8
src/Dto/CachableDtoInterface.php
Normal file
8
src/Dto/CachableDtoInterface.php
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Dto;
|
||||||
|
|
||||||
|
interface CachableDtoInterface
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
14
src/Dto/Condition/ConditionNotNull.php
Normal file
14
src/Dto/Condition/ConditionNotNull.php
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
23
src/Dto/SnipFilterRequest.php
Normal file
23
src/Dto/SnipFilterRequest.php
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Dto;
|
||||||
|
|
||||||
|
readonly class SnipFilterRequest implements CachableDtoInterface
|
||||||
|
{
|
||||||
|
public const string VISIBILITY_ALL = 'all';
|
||||||
|
public const string VISIBILITY_VISIBLE = 'visible';
|
||||||
|
public const string VISIBILITY_HIDDEN = 'hidden';
|
||||||
|
public const string VISIBILITY_ARCHIVED = 'archived';
|
||||||
|
|
||||||
|
public const string SORT_NAME = 'name';
|
||||||
|
public const string SORT_DATE = 'date';
|
||||||
|
|
||||||
|
public const string TAG_ALL = 'all';
|
||||||
|
public const string TAG_NONE = 'none';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public ?string $visibility = self::VISIBILITY_VISIBLE,
|
||||||
|
public ?string $sort = self::SORT_NAME,
|
||||||
|
public ?string $tag = self::TAG_ALL,
|
||||||
|
) {}
|
||||||
|
}
|
22
src/Dto/SnipPostRequest.php
Normal file
22
src/Dto/SnipPostRequest.php
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Dto;
|
||||||
|
|
||||||
|
use App\Dto\Condition\ConditionNotNull;
|
||||||
|
use App\Entity\Snip;
|
||||||
|
use Symfony\Component\ObjectMapper\Attribute\Map;
|
||||||
|
|
||||||
|
#[Map(target: Snip::class)]
|
||||||
|
class SnipPostRequest
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
#[Map(if: new ConditionNotNull())]
|
||||||
|
public ?string $name = null,
|
||||||
|
public ?string $content = null,
|
||||||
|
#[Map(if: new ConditionNotNull())]
|
||||||
|
public ?bool $public = null,
|
||||||
|
#[Map(if: new ConditionNotNull())]
|
||||||
|
public ?bool $visible = null,
|
||||||
|
public ?string $contentName = null,
|
||||||
|
) {}
|
||||||
|
}
|
@@ -10,39 +10,22 @@ trait TrackedTrait
|
|||||||
{
|
{
|
||||||
#[ORM\Column]
|
#[ORM\Column]
|
||||||
#[ORM\JoinColumn(nullable: false)]
|
#[ORM\JoinColumn(nullable: false)]
|
||||||
private ?DateTime $createdAt = null;
|
public ?DateTime $createdAt = null;
|
||||||
|
|
||||||
#[ORM\ManyToOne]
|
#[ORM\ManyToOne]
|
||||||
#[ORM\JoinColumn(nullable: false)]
|
#[ORM\JoinColumn(nullable: false)]
|
||||||
private ?User $createdBy = null;
|
public ?User $createdBy = null;
|
||||||
|
|
||||||
public function getCreatedBy(): ?User
|
public function setCreatedAtNowNoSeconds(): self
|
||||||
{
|
{
|
||||||
return $this->createdBy;
|
$this->createdAt = DateTime::createFromFormat('Y-m-d H:i', date('Y-m-d H:i'));
|
||||||
}
|
|
||||||
|
|
||||||
public function setCreatedBy(?User $createdBy): self
|
|
||||||
{
|
|
||||||
$this->createdBy = $createdBy;
|
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getCreatedAt(): ?DateTime
|
public function setCreatedAtNow(): self
|
||||||
{
|
{
|
||||||
return $this->createdAt;
|
$this->createdAt = new DateTime();
|
||||||
}
|
|
||||||
|
|
||||||
public function setCreatedAt(DateTime $createdAt): self
|
|
||||||
{
|
|
||||||
$this->createdAt = $createdAt;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setCreatedAtTodayNoSeconds(): self
|
|
||||||
{
|
|
||||||
$this->setCreatedAt(DateTime::createFromFormat('Y-m-d H:i', date('Y-m-d H:i')));
|
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
@@ -4,6 +4,7 @@ namespace App\Entity;
|
|||||||
|
|
||||||
use App\Entity\Helpers\TrackedTrait;
|
use App\Entity\Helpers\TrackedTrait;
|
||||||
use App\Repository\SnipRepository;
|
use App\Repository\SnipRepository;
|
||||||
|
use App\Service\SnipContent\SnipContentService;
|
||||||
use Doctrine\Common\Collections\ArrayCollection;
|
use Doctrine\Common\Collections\ArrayCollection;
|
||||||
use Doctrine\Common\Collections\Collection;
|
use Doctrine\Common\Collections\Collection;
|
||||||
use Doctrine\ORM\Mapping as ORM;
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
@@ -16,23 +17,39 @@ class Snip
|
|||||||
#[ORM\Id]
|
#[ORM\Id]
|
||||||
#[ORM\GeneratedValue]
|
#[ORM\GeneratedValue]
|
||||||
#[ORM\Column]
|
#[ORM\Column]
|
||||||
private ?int $id = null;
|
public ?int $id = null;
|
||||||
|
|
||||||
#[ORM\Column(length: 255)]
|
#[ORM\Column(length: 255)]
|
||||||
private ?string $name = null;
|
public ?string $name = null;
|
||||||
|
|
||||||
#[ORM\Column]
|
#[ORM\Column]
|
||||||
private ?bool $public = null;
|
public bool $public = false;
|
||||||
|
|
||||||
#[ORM\OneToMany(mappedBy: 'snip', targetEntity: SnipContent::class, orphanRemoval: true)]
|
#[ORM\OneToMany(targetEntity: SnipContent::class, mappedBy: 'snip', orphanRemoval: true)]
|
||||||
private Collection $snipContents;
|
public Collection $snipContents;
|
||||||
|
|
||||||
#[ORM\Column(length: 255, nullable: true)]
|
#[ORM\OneToOne]
|
||||||
private ?string $activeCommit = null;
|
public ?SnipContent $activeVersion = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255)]
|
||||||
|
public ?string $parser = null;
|
||||||
|
|
||||||
|
#[ORM\Column]
|
||||||
|
public bool $visible = true;
|
||||||
|
|
||||||
|
#[ORM\Column]
|
||||||
|
public bool $archived = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var Collection<int, Tag>
|
||||||
|
*/
|
||||||
|
#[ORM\ManyToMany(targetEntity: Tag::class, mappedBy: 'snips')]
|
||||||
|
public Collection $tags;
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->snipContents = new ArrayCollection();
|
$this->snipContents = new ArrayCollection();
|
||||||
|
$this->tags = new ArrayCollection();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function __toString(): string
|
public function __toString(): string
|
||||||
@@ -40,48 +57,16 @@ class Snip
|
|||||||
return $this->name ?? '';
|
return $this->name ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getId(): ?int
|
public function getActiveText(): string
|
||||||
{
|
{
|
||||||
return $this->id;
|
return SnipContentService::rebuildText($this->activeVersion);
|
||||||
}
|
|
||||||
|
|
||||||
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->setSnip($this);
|
$snipContent->snip = $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
@@ -91,22 +76,34 @@ 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->getSnip() === $this) {
|
if ($snipContent->snip === $this) {
|
||||||
$snipContent->setSnip(null);
|
$snipContent->snip = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getActiveCommit(): ?string
|
public function getLatestVersion(): ?SnipContent
|
||||||
{
|
{
|
||||||
return $this->activeCommit;
|
return $this->snipContents->last() ?: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setActiveCommit(?string $activeCommit): static
|
public function addTag(Tag $tag): static
|
||||||
{
|
{
|
||||||
$this->activeCommit = $activeCommit;
|
if (!$this->tags->contains($tag)) {
|
||||||
|
$this->tags->add($tag);
|
||||||
|
$tag->addSnip($this);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function removeTag(Tag $tag): static
|
||||||
|
{
|
||||||
|
if ($this->tags->removeElement($tag)) {
|
||||||
|
$tag->removeSnip($this);
|
||||||
|
}
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
@@ -17,94 +17,30 @@ 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')]
|
||||||
private ?Ulid $id = null;
|
public ?Ulid $id = null;
|
||||||
|
|
||||||
#[ORM\ManyToOne(inversedBy: 'snipContents')]
|
#[ORM\ManyToOne(inversedBy: 'snipContents')]
|
||||||
#[ORM\JoinColumn(nullable: false)]
|
#[ORM\JoinColumn(nullable: false)]
|
||||||
private ?Snip $snip = null;
|
public ?Snip $snip = null;
|
||||||
|
|
||||||
#[ORM\ManyToOne(targetEntity: self::class, inversedBy: 'children')]
|
#[ORM\ManyToOne(targetEntity: self::class, inversedBy: 'children')]
|
||||||
private ?self $parent = null;
|
public ?self $parent = null;
|
||||||
|
|
||||||
#[ORM\OneToMany(mappedBy: 'parent', targetEntity: self::class)]
|
/** @var Collection<int, self> */
|
||||||
private Collection $children;
|
#[ORM\OneToMany(targetEntity: self::class, mappedBy: 'parent')]
|
||||||
|
public Collection $children;
|
||||||
|
|
||||||
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||||
private ?string $text = null;
|
public ?string $text = null;
|
||||||
|
|
||||||
|
#[ORM\Column(nullable: true)]
|
||||||
|
public ?array $diff = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255, nullable: true)]
|
||||||
|
public ?string $name = null;
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->children = new ArrayCollection();
|
$this->children = new ArrayCollection();
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
61
src/Entity/Tag.php
Normal file
61
src/Entity/Tag.php
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use App\Dto\SnipFilterRequest;
|
||||||
|
use App\Repository\TagRepository;
|
||||||
|
use Doctrine\Common\Collections\ArrayCollection;
|
||||||
|
use Doctrine\Common\Collections\Collection;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
|
||||||
|
#[ORM\Entity(repositoryClass: TagRepository::class)]
|
||||||
|
#[ORM\UniqueConstraint(name: 'user_tag_unique', columns: ['name', 'user_id'])]
|
||||||
|
class Tag
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column]
|
||||||
|
public ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255)]
|
||||||
|
#[Assert\NotEqualTo(SnipFilterRequest::TAG_ALL)]
|
||||||
|
#[Assert\NotEqualTo(SnipFilterRequest::TAG_NONE)]
|
||||||
|
public ?string $name = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne]
|
||||||
|
#[ORM\JoinColumn(nullable: false)]
|
||||||
|
public ?User $user = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var Collection<int, Snip>
|
||||||
|
*/
|
||||||
|
#[ORM\ManyToMany(targetEntity: Snip::class, inversedBy: 'tags')]
|
||||||
|
public Collection $snips;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->snips = new ArrayCollection();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __toString(): string
|
||||||
|
{
|
||||||
|
return $this->name ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function addSnip(Snip $snip): static
|
||||||
|
{
|
||||||
|
if (!$this->snips->contains($snip)) {
|
||||||
|
$this->snips->add($snip);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function removeSnip(Snip $snip): static
|
||||||
|
{
|
||||||
|
$this->snips->removeElement($snip);
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
@@ -34,6 +34,9 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
|||||||
#[ORM\Column(length: 255)]
|
#[ORM\Column(length: 255)]
|
||||||
private ?string $email = null;
|
private ?string $email = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255, nullable: true)]
|
||||||
|
private ?string $apiKey = null;
|
||||||
|
|
||||||
public function __toString(): string
|
public function __toString(): string
|
||||||
{
|
{
|
||||||
return $this->name ?? '';
|
return $this->name ?? '';
|
||||||
@@ -132,4 +135,16 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
|||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getApiKey(): ?string
|
||||||
|
{
|
||||||
|
return $this->apiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setApiKey(?string $apiKey): static
|
||||||
|
{
|
||||||
|
$this->apiKey = $apiKey;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
}
|
}
|
@@ -3,22 +3,40 @@
|
|||||||
namespace App\Form;
|
namespace App\Form;
|
||||||
|
|
||||||
use App\Entity\Snip;
|
use App\Entity\Snip;
|
||||||
|
use App\Service\SnipParser\ParserFactory;
|
||||||
use Symfony\Component\Form\AbstractType;
|
use Symfony\Component\Form\AbstractType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||||
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
|
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||||
use Symfony\Component\Form\FormBuilderInterface;
|
use Symfony\Component\Form\FormBuilderInterface;
|
||||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||||
|
|
||||||
class SnipType extends AbstractType
|
class SnipType extends AbstractType
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ParserFactory $parserFactory,
|
||||||
|
) {}
|
||||||
|
|
||||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||||
{
|
{
|
||||||
$builder
|
$builder
|
||||||
->add('name')
|
->add('name')
|
||||||
|
->add('parser', ChoiceType::class, [
|
||||||
|
'choice_label' => fn(string $parser) => ucfirst($parser),
|
||||||
|
'choices' => $this->parserFactory->getChoices(),
|
||||||
|
])
|
||||||
->add('content', TextareaType::class, [
|
->add('content', TextareaType::class, [
|
||||||
'attr' => ['rows' => 20],
|
'attr' => ['rows' => 20],
|
||||||
'mapped' => false,
|
'mapped' => false,
|
||||||
])
|
])
|
||||||
->add('public')
|
->add('tags', TagsType::class)
|
||||||
|
->add('public', SwitchType::class)
|
||||||
|
->add('visible', SwitchType::class)
|
||||||
|
->add('contentName', TextType::class, [
|
||||||
|
'label' => 'Change description (optional)',
|
||||||
|
'mapped' => false,
|
||||||
|
'required' => false,
|
||||||
|
])
|
||||||
;
|
;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
22
src/Form/SwitchType.php
Normal file
22
src/Form/SwitchType.php
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Form;
|
||||||
|
|
||||||
|
use Symfony\Component\Form\AbstractType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||||
|
use Symfony\Component\Form\FormInterface;
|
||||||
|
use Symfony\Component\Form\FormView;
|
||||||
|
|
||||||
|
class SwitchType extends AbstractType
|
||||||
|
{
|
||||||
|
public function getParent(): string
|
||||||
|
{
|
||||||
|
return CheckboxType::class;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function buildView(FormView $view, FormInterface $form, array $options): void
|
||||||
|
{
|
||||||
|
$view->vars['label_attr']['class'] = trim(($view->vars['label_attr']['class'] ?? '') . ' checkbox-switch');
|
||||||
|
$view->vars['required'] = false;
|
||||||
|
}
|
||||||
|
}
|
94
src/Form/TagsType.php
Normal file
94
src/Form/TagsType.php
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Form;
|
||||||
|
|
||||||
|
use App\Entity\Tag;
|
||||||
|
use App\Repository\TagRepository;
|
||||||
|
use Doctrine\Common\Collections\Collection;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\Form\AbstractType;
|
||||||
|
use Symfony\Component\Form\DataTransformerInterface;
|
||||||
|
use Symfony\Component\Form\Exception\TransformationFailedException;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||||
|
use Symfony\Component\Form\FormBuilderInterface;
|
||||||
|
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||||
|
use Symfony\Component\Validator\ConstraintViolationInterface;
|
||||||
|
use Symfony\Component\Validator\Validator\ValidatorInterface;
|
||||||
|
|
||||||
|
class TagsType extends AbstractType implements DataTransformerInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly TagRepository $repository,
|
||||||
|
private readonly Security $security,
|
||||||
|
private readonly ValidatorInterface $validator,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function transform($value): string
|
||||||
|
{
|
||||||
|
if ($value === null) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($value instanceof Collection) {
|
||||||
|
$value = $value->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_array($value)) {
|
||||||
|
$tags = array_map(fn(Tag $tag) => $tag->name, $value);
|
||||||
|
} else {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return implode(', ', $tags);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reverseTransform($value): array
|
||||||
|
{
|
||||||
|
$tags = array_filter(array_map('trim', explode(',', $value)));
|
||||||
|
$user = $this->security->getUser();
|
||||||
|
|
||||||
|
$tagEntities = [];
|
||||||
|
foreach ($tags as $tag) {
|
||||||
|
$tagEntity = $this->repository->findOneBy(['name' => $tag, 'user' => $user]);
|
||||||
|
if ($tagEntity === null) {
|
||||||
|
$tagEntity = new Tag();
|
||||||
|
$tagEntity->name = $tag;
|
||||||
|
$tagEntity->user = $user;
|
||||||
|
|
||||||
|
// Validate the new Tag entity
|
||||||
|
$errors = $this->validator->validate($tagEntity);
|
||||||
|
if (count($errors) > 0) {
|
||||||
|
$exception = new TransformationFailedException();
|
||||||
|
$exception->setInvalidMessage(implode(', ', array_map(
|
||||||
|
fn(ConstraintViolationInterface $error) => $error->getMessage(),
|
||||||
|
iterator_to_array($errors)
|
||||||
|
)));
|
||||||
|
throw $exception;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->repository->save($tagEntity);
|
||||||
|
}
|
||||||
|
$tagEntities[] = $tagEntity;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $tagEntities;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||||
|
{
|
||||||
|
$builder->addModelTransformer($this);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function configureOptions(OptionsResolver $resolver): void
|
||||||
|
{
|
||||||
|
$resolver->setDefaults([
|
||||||
|
'data_class' => null, // No specific entity class
|
||||||
|
'label' => 'Tags (comma-separated)',
|
||||||
|
'required' => false,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getParent(): string
|
||||||
|
{
|
||||||
|
return TextType::class;
|
||||||
|
}
|
||||||
|
}
|
@@ -1,13 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Git;
|
|
||||||
|
|
||||||
use CzProject\GitPhp\Git;
|
|
||||||
|
|
||||||
class CustomGit extends Git
|
|
||||||
{
|
|
||||||
public function open($directory): CustomGitRepository
|
|
||||||
{
|
|
||||||
return new CustomGitRepository($directory, $this->runner);
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,33 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Git;
|
|
||||||
|
|
||||||
use CzProject\GitPhp\GitRepository;
|
|
||||||
use DateTime;
|
|
||||||
|
|
||||||
class CustomGitRepository extends GitRepository
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @return array<SimpleCommit>
|
|
||||||
* @throws \CzProject\GitPhp\GitException
|
|
||||||
*/
|
|
||||||
public function getAllCommits(): array
|
|
||||||
{
|
|
||||||
$result = $this->run('log', '--pretty=%H,%cI');
|
|
||||||
|
|
||||||
if (empty($result->getOutput())) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$commits = [];
|
|
||||||
foreach ($result->getOutput() as $line) {
|
|
||||||
$parts = explode(',', $line);
|
|
||||||
$commits[] = new SimpleCommit(
|
|
||||||
$parts[0],
|
|
||||||
new DateTime($parts[1])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $commits;
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,26 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Git;
|
|
||||||
|
|
||||||
use DateTime;
|
|
||||||
|
|
||||||
class SimpleCommit
|
|
||||||
{
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
private readonly string $hash,
|
|
||||||
private readonly DateTime $date,
|
|
||||||
)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getHash(): string
|
|
||||||
{
|
|
||||||
return $this->hash;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getDate(): DateTime
|
|
||||||
{
|
|
||||||
return $this->date;
|
|
||||||
}
|
|
||||||
}
|
|
0
src/Repository/.gitignore
vendored
0
src/Repository/.gitignore
vendored
@@ -2,10 +2,12 @@
|
|||||||
|
|
||||||
namespace App\Repository;
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Dto\SnipFilterRequest;
|
||||||
use App\Entity\Snip;
|
use App\Entity\Snip;
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
use Doctrine\Persistence\ManagerRegistry;
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
use Symfony\Component\Security\Core\User\UserInterface;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @extends ServiceEntityRepository<Snip>
|
* @extends ServiceEntityRepository<Snip>
|
||||||
@@ -40,24 +42,72 @@ class SnipRepository extends ServiceEntityRepository
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function findByUser(User $user): array
|
public function findByRequest(UserInterface $user, SnipFilterRequest $request): array
|
||||||
{
|
{
|
||||||
$qb = $this->createQueryBuilder('s');
|
$qb = $this
|
||||||
$qb->where('s.createdBy = :user')
|
->createQueryBuilder('s')
|
||||||
|
->where('s.createdBy = :user')
|
||||||
->setParameter('user', $user)
|
->setParameter('user', $user)
|
||||||
->orderBy('s.createdAt', 'DESC');
|
;
|
||||||
|
|
||||||
|
$showArchived = false;
|
||||||
|
switch ($request->visibility) {
|
||||||
|
case SnipFilterRequest::VISIBILITY_ALL:
|
||||||
|
break;
|
||||||
|
case SnipFilterRequest::VISIBILITY_VISIBLE:
|
||||||
|
$qb->andWhere('s.visible = true');
|
||||||
|
break;
|
||||||
|
case SnipFilterRequest::VISIBILITY_HIDDEN:
|
||||||
|
$qb->andWhere('s.visible = false');
|
||||||
|
break;
|
||||||
|
case SnipFilterRequest::VISIBILITY_ARCHIVED:
|
||||||
|
$showArchived = true;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new \InvalidArgumentException('Invalid visibility option: ', $request->visibility);
|
||||||
|
}
|
||||||
|
$qb->andWhere('s.archived = ' . ($showArchived ? 'true' : 'false'));
|
||||||
|
|
||||||
|
switch ($request->sort) {
|
||||||
|
case SnipFilterRequest::SORT_NAME:
|
||||||
|
$qb->orderBy('s.name', 'ASC');
|
||||||
|
break;
|
||||||
|
case SnipFilterRequest::SORT_DATE:
|
||||||
|
$qb->orderBy('s.createdAt', 'DESC');
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new \InvalidArgumentException('Invalid sort option: ', $request->sort);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->tag === 'none') {
|
||||||
|
$qb->andWhere('s.tags IS EMPTY');
|
||||||
|
} elseif ($request->tag === 'all') {
|
||||||
|
// No filter needed
|
||||||
|
} else {
|
||||||
|
$qb->innerJoin('s.tags', 't')
|
||||||
|
->andWhere('t.name = :tag')
|
||||||
|
->setParameter('tag', $request->tag)
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
return $qb->getQuery()->getResult();
|
return $qb->getQuery()->getResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function findPublic(User $user): array
|
public function findPublic(?User $user = null): array
|
||||||
{
|
{
|
||||||
$qb = $this->createQueryBuilder('s');
|
$qb = $this
|
||||||
$qb->where('s.public = :public')
|
->createQueryBuilder('s')
|
||||||
->andWhere('s.createdBy != :user')
|
->where('s.public = true')
|
||||||
->setParameter('public', true)
|
->andWhere('s.visible = true')
|
||||||
|
->andWhere('s.archived = false')
|
||||||
|
->orderBy('s.createdAt', 'DESC')
|
||||||
|
;
|
||||||
|
|
||||||
|
if ($user) {
|
||||||
|
$qb->andWhere('s.createdBy != :user')
|
||||||
->setParameter('user', $user)
|
->setParameter('user', $user)
|
||||||
->orderBy('s.createdAt', 'DESC');
|
;
|
||||||
|
}
|
||||||
|
|
||||||
return $qb->getQuery()->getResult();
|
return $qb->getQuery()->getResult();
|
||||||
}
|
}
|
||||||
|
30
src/Repository/TagRepository.php
Normal file
30
src/Repository/TagRepository.php
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\Tag;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
use Symfony\Component\Security\Core\User\UserInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends ServiceEntityRepository<Tag>
|
||||||
|
*/
|
||||||
|
class TagRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, Tag::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save(Tag $tag): void
|
||||||
|
{
|
||||||
|
$this->getEntityManager()->persist($tag);
|
||||||
|
$this->getEntityManager()->flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findAllByUser(UserInterface $user): array
|
||||||
|
{
|
||||||
|
return $this->findBy(['user' => $user]);
|
||||||
|
}
|
||||||
|
}
|
68
src/Security/TokenAuthenticator.php
Normal file
68
src/Security/TokenAuthenticator.php
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Security;
|
||||||
|
|
||||||
|
use App\Entity\User;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||||
|
use Symfony\Component\Security\Core\Exception\AuthenticationException;
|
||||||
|
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
|
||||||
|
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
|
||||||
|
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
|
||||||
|
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
|
||||||
|
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
|
||||||
|
|
||||||
|
class TokenAuthenticator extends AbstractAuthenticator
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly EntityManagerInterface $em,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called on every request to decide if this authenticator should be
|
||||||
|
* used for the request. Returning false will cause this authenticator
|
||||||
|
* to be skipped.
|
||||||
|
*/
|
||||||
|
public function supports(Request $request): ?bool
|
||||||
|
{
|
||||||
|
return $request->headers->has('X-AUTH-TOKEN');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function authenticate(Request $request): Passport
|
||||||
|
{
|
||||||
|
$apiToken = $request->headers->get('X-AUTH-TOKEN');
|
||||||
|
if (empty($apiToken)) {
|
||||||
|
// The token header was empty, authentication fails with HTTP Status
|
||||||
|
// Code 401 "Unauthorized"
|
||||||
|
throw new CustomUserMessageAuthenticationException('No API token provided');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SelfValidatingPassport(
|
||||||
|
new UserBadge($apiToken, function ($apiToken) {
|
||||||
|
return $this->em->getRepository(User::class)->findOneBy(['apiKey' => $apiToken]);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
|
||||||
|
{
|
||||||
|
// on success, let the request continue
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
|
||||||
|
{
|
||||||
|
$data = [
|
||||||
|
// you may want to customize or obfuscate the message first
|
||||||
|
'message' => strtr($exception->getMessageKey(), $exception->getMessageData()),
|
||||||
|
|
||||||
|
// or to translate this message
|
||||||
|
// $this->translator->trans($exception->getMessageKey(), $exception->getMessageData())
|
||||||
|
];
|
||||||
|
|
||||||
|
return new JsonResponse($data, Response::HTTP_UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
}
|
@@ -4,13 +4,14 @@ 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 EDIT = 'edit';
|
public const string EDIT = 'edit';
|
||||||
public const VIEW = 'view';
|
public const string VIEW = 'view';
|
||||||
|
|
||||||
protected function supports(string $attribute, mixed $subject): bool
|
protected function supports(string $attribute, mixed $subject): bool
|
||||||
{
|
{
|
||||||
@@ -20,21 +21,22 @@ class SnipVoter extends Voter
|
|||||||
&& $subject instanceof Snip;
|
&& $subject instanceof Snip;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
|
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token,/* , ?Vote $vote = null */): bool
|
||||||
{
|
{
|
||||||
/** @var Snip $subject */
|
/** @var Snip $subject */
|
||||||
|
|
||||||
|
$user = $token->getUser();
|
||||||
|
|
||||||
switch ($attribute) {
|
switch ($attribute) {
|
||||||
case self::VIEW:
|
case self::VIEW:
|
||||||
if ($subject->isPublic()) {
|
if ($subject->public) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
case self::EDIT:
|
case self::EDIT:
|
||||||
$user = $token->getUser();
|
|
||||||
if (!$user instanceof UserInterface) {
|
if (!$user instanceof UserInterface) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if ($subject->getCreatedBy() === $user) {
|
if ($subject->createdBy === $user) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Service;
|
namespace App\Service;
|
||||||
|
|
||||||
use JetBrains\PhpStorm\ArrayShape;
|
use JetBrains\PhpStorm\ArrayShape;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
|
||||||
class LastRelease
|
class LastRelease
|
||||||
{
|
{
|
||||||
@@ -18,7 +19,7 @@ class LastRelease
|
|||||||
])]
|
])]
|
||||||
private array $lastRelease = [];
|
private array $lastRelease = [];
|
||||||
|
|
||||||
public function __construct(string $jsonFile)
|
public function __construct(#[Autowire('%kernel.project_dir%/release.json')] string $jsonFile)
|
||||||
{
|
{
|
||||||
if (file_exists($jsonFile)) {
|
if (file_exists($jsonFile)) {
|
||||||
$this->lastRelease = json_decode(file_get_contents($jsonFile), true);
|
$this->lastRelease = json_decode(file_get_contents($jsonFile), true);
|
||||||
|
65
src/Service/RequestDtoCache.php
Normal file
65
src/Service/RequestDtoCache.php
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Service;
|
||||||
|
|
||||||
|
use App\Dto\CachableDtoInterface;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use ReflectionClass;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpKernel\Controller\ValueResolverInterface;
|
||||||
|
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;
|
||||||
|
|
||||||
|
class RequestDtoCache implements ValueResolverInterface
|
||||||
|
{
|
||||||
|
private const string SESSION_CACHE_PREFIX = 'dto.';
|
||||||
|
|
||||||
|
public function resolve(Request $request, ArgumentMetadata $argument): iterable
|
||||||
|
{
|
||||||
|
$session = $request->getSession();
|
||||||
|
$className = $argument->getType();
|
||||||
|
if (!$className || !is_subclass_of($className, CachableDtoInterface::class)
|
||||||
|
) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$reflection = new ReflectionClass($className);
|
||||||
|
$constructor = $reflection->getConstructor();
|
||||||
|
|
||||||
|
if (!$constructor) {
|
||||||
|
return []; // No constructor: return empty instance
|
||||||
|
}
|
||||||
|
|
||||||
|
$cacheKey = self::SESSION_CACHE_PREFIX . (implode('.', [
|
||||||
|
$argument->getControllerName(),
|
||||||
|
$argument->getName(),
|
||||||
|
$className,
|
||||||
|
]));
|
||||||
|
|
||||||
|
$cacheData = $session->get($cacheKey, []);
|
||||||
|
$queryData = $request->query->all();
|
||||||
|
$params = $constructor->getParameters();
|
||||||
|
|
||||||
|
if (isset($queryData['reset'])) {
|
||||||
|
$queryData = $cacheData = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$args = [];
|
||||||
|
foreach ($params as $param) {
|
||||||
|
$name = $param->getName();
|
||||||
|
if (array_key_exists($name, $queryData)) {
|
||||||
|
$args[$name] = $queryData[$name];
|
||||||
|
} elseif (array_key_exists($name, $cacheData)) {
|
||||||
|
$args[$name] = $cacheData[$name];
|
||||||
|
} elseif ($param->isDefaultValueAvailable()) {
|
||||||
|
$args[$name] = $param->getDefaultValue();
|
||||||
|
} else {
|
||||||
|
throw new InvalidArgumentException(sprintf('Missing required parameter "%s" for class "%s"', $name, $className));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the cache data in the session
|
||||||
|
$session->set($cacheKey, $args);
|
||||||
|
|
||||||
|
yield $reflection->newInstanceArgs($args);
|
||||||
|
}
|
||||||
|
}
|
15
src/Service/SnipContent/DiffTypeEnum.php
Normal file
15
src/Service/SnipContent/DiffTypeEnum.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
16
src/Service/SnipContent/Lexer.php
Normal file
16
src/Service/SnipContent/Lexer.php
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Service\SnipContent;
|
||||||
|
|
||||||
|
class Lexer
|
||||||
|
{
|
||||||
|
public static function tokenize(string $text): array {
|
||||||
|
$text = str_replace("\r", '', $text);
|
||||||
|
return explode(PHP_EOL, $text);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function reconstruct(array $tokens): string
|
||||||
|
{
|
||||||
|
return implode(PHP_EOL, $tokens);
|
||||||
|
}
|
||||||
|
}
|
222
src/Service/SnipContent/MyersDiff.php
Normal file
222
src/Service/SnipContent/MyersDiff.php
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Service\SnipContent;
|
||||||
|
|
||||||
|
class MyersDiff
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 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[] = [DiffTypeEnum::DELETE->value, $count];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insertions
|
||||||
|
while ($snake[0] - $snake[1] < $x - $y) {
|
||||||
|
$values = [];
|
||||||
|
while ($snake[0] - $snake[1] < $x - $y) {
|
||||||
|
$values[] = $b[$y];
|
||||||
|
$y++;
|
||||||
|
}
|
||||||
|
$solutionKey = count($solution) - 1;
|
||||||
|
if ($solutionKey >= 0 && DiffTypeEnum::INSERT->is($solution[$solutionKey][0])) {
|
||||||
|
$solution[$solutionKey][1] = array_merge($solution[$solutionKey][1], $values);
|
||||||
|
} else {
|
||||||
|
$solution[] = [DiffTypeEnum::INSERT->value, $values];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keeps (snake diagonals)
|
||||||
|
$count = 0;
|
||||||
|
while ($x < $snake[0]) {
|
||||||
|
$x++;
|
||||||
|
$y++;
|
||||||
|
$count++;
|
||||||
|
}
|
||||||
|
if ($count > 0) {
|
||||||
|
$solution[] = [DiffTypeEnum::KEEP->value, $count];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $solution;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the shortest edit sequence to convert $x into $y.
|
||||||
|
*
|
||||||
|
* @param string|array $textFrom - 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.
|
||||||
|
*
|
||||||
|
* @return array[] - pairs of token and edit (-1 for delete, 0 for keep, +1 for insert)
|
||||||
|
*/
|
||||||
|
public static function calculate(string|array $textFrom, string|array $textTo, ?callable $compare = null): array
|
||||||
|
{
|
||||||
|
if (is_string($textFrom)) {
|
||||||
|
$a = Lexer::tokenize($textFrom);
|
||||||
|
} else {
|
||||||
|
$a = $textFrom;
|
||||||
|
}
|
||||||
|
if (is_string($textTo)) {
|
||||||
|
$b = Lexer::tokenize($textTo);
|
||||||
|
} else {
|
||||||
|
$b = $textTo;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($compare === null) {
|
||||||
|
$compare = fn($x, $y) => $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 = Lexer::tokenize($textFrom);
|
||||||
|
$b = [];
|
||||||
|
$x = 0;
|
||||||
|
|
||||||
|
foreach ($diff as [$op, $data]) {
|
||||||
|
switch ($op) {
|
||||||
|
case DiffTypeEnum::KEEP->value:
|
||||||
|
for ($i = 0; $i < $data; $i++) {
|
||||||
|
$b[] = $a[$x++];
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case DiffTypeEnum::DELETE->value:
|
||||||
|
$x += $data; // skip deleted
|
||||||
|
break;
|
||||||
|
case DiffTypeEnum::INSERT->value:
|
||||||
|
foreach ($data as $v) {
|
||||||
|
$b[] = $v;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new \InvalidArgumentException('Invalid diff operation');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Lexer::reconstruct($b);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function buildDiffLines(string $textFrom, string $textTo): array
|
||||||
|
{
|
||||||
|
$a = Lexer::tokenize($textFrom);
|
||||||
|
$b = Lexer::tokenize($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[] = [
|
||||||
|
'line' => $x,
|
||||||
|
'type' => 'keep',
|
||||||
|
'from' => $a[$x],
|
||||||
|
'to' => $a[$x],
|
||||||
|
];
|
||||||
|
$x++;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case DiffTypeEnum::DELETE->value:
|
||||||
|
for ($i = 0; $i < $data; $i++) {
|
||||||
|
$lines[] = [
|
||||||
|
'line' => $x,
|
||||||
|
'type' => 'delete',
|
||||||
|
'from' => $a[$x],
|
||||||
|
'to' => '',
|
||||||
|
];
|
||||||
|
$x++;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case DiffTypeEnum::INSERT->value:
|
||||||
|
foreach ($data as $v) {
|
||||||
|
$lines[] = [
|
||||||
|
'line' => $x,
|
||||||
|
'type' => 'insert',
|
||||||
|
'from' => '',
|
||||||
|
'to' => $v,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new \InvalidArgumentException('Invalid diff operation');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $lines;
|
||||||
|
}
|
||||||
|
}
|
@@ -1,74 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Service\SnipContent;
|
|
||||||
|
|
||||||
use App\Entity\Snip;
|
|
||||||
use App\Entity\SnipContent;
|
|
||||||
use App\Entity\User;
|
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
|
||||||
|
|
||||||
readonly class SnipContentDB implements SnipContentInterface
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private Snip $snip,
|
|
||||||
private User $user,
|
|
||||||
private EntityManagerInterface $em,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function update(string $snipContents): void
|
|
||||||
{
|
|
||||||
// Create new snipContent entity with previous one as parent
|
|
||||||
$content = new SnipContent();
|
|
||||||
$content
|
|
||||||
->setText($snipContents)
|
|
||||||
->setSnip($this->snip)
|
|
||||||
;
|
|
||||||
if ($this->snip->getSnipContents()->count() > 0) {
|
|
||||||
$content->setParent($this->snip->getSnipContents()->last());
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->em->persist($content);
|
|
||||||
$this->em->flush();
|
|
||||||
|
|
||||||
$this->snip->setActiveCommit($content->getId());
|
|
||||||
$this->em->persist($this->snip);
|
|
||||||
$this->em->flush();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function get(): string
|
|
||||||
{
|
|
||||||
$contentRepo = $this->em->getRepository(SnipContent::class);
|
|
||||||
return $contentRepo->find($this->snip->getActiveCommit())->getText();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getVersions(): array
|
|
||||||
{
|
|
||||||
// Return all snipContent entities (by parent)
|
|
||||||
return array_map(fn(SnipContent $content) => [
|
|
||||||
'id' => (string)$content->getId(),
|
|
||||||
'name' => $content->getId()->getDateTime()->format('Y-m-d H:i:s'),
|
|
||||||
], $this->snip->getSnipContents()->toArray());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setVersion(string $version): void
|
|
||||||
{
|
|
||||||
$this->snip->setActiveCommit($version);
|
|
||||||
$this->em->persist($this->snip);
|
|
||||||
$this->em->flush();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getCommit(): string
|
|
||||||
{
|
|
||||||
return $this->snip->getActiveCommit();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function delete(): void
|
|
||||||
{
|
|
||||||
// Cleanup the history
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getLatestVersion(): string
|
|
||||||
{
|
|
||||||
return $this->snip->getSnipContents()->last()->getId();
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,80 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Service\SnipContent;
|
|
||||||
|
|
||||||
use App\Entity\User;
|
|
||||||
use App\Git\CustomGitRepository;
|
|
||||||
use App\Git\SimpleCommit;
|
|
||||||
use Symfony\Component\Security\Core\User\UserInterface;
|
|
||||||
|
|
||||||
class SnipContentGit implements SnipContentInterface
|
|
||||||
{
|
|
||||||
private const string SNIP_FILE_NAME = 'snip.txt';
|
|
||||||
private const string MASTER_BRANCH_NAME = 'master';
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
private readonly CustomGitRepository $repo,
|
|
||||||
private readonly ?User $user,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
private function snipExists(): bool
|
|
||||||
{
|
|
||||||
return file_exists($this->getSnipPath());
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getSnipPath(): string
|
|
||||||
{
|
|
||||||
return sprintf('%s/snip.txt', $this->repo->getRepositoryPath());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function update(string $snipContents): void
|
|
||||||
{
|
|
||||||
if (!$this->user instanceof UserInterface) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if ($this->repo->getCurrentBranchName() !== self::MASTER_BRANCH_NAME) {
|
|
||||||
$this->repo->checkout(self::MASTER_BRANCH_NAME);
|
|
||||||
}
|
|
||||||
file_put_contents($this->getSnipPath(), $snipContents);
|
|
||||||
$this->repo->addFile(self::SNIP_FILE_NAME);
|
|
||||||
if ($this->repo->hasChanges()) {
|
|
||||||
$this->repo->commit(sprintf('Updated snip at %s by %s', date('Y-m-d H:i:s'), $this->user));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function get(): string
|
|
||||||
{
|
|
||||||
if (!$this->snipExists()) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
return file_get_contents($this->getSnipPath());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getVersions(): array
|
|
||||||
{
|
|
||||||
return array_map(fn(SimpleCommit $c) => [
|
|
||||||
'id' => $c->getHash(),
|
|
||||||
'name' => $c->getDate()->format('Y-m-d H:i:s'),
|
|
||||||
], $this->repo->getAllCommits());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setVersion(string $version): void
|
|
||||||
{
|
|
||||||
$this->repo->checkout($version);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getCommit(): string
|
|
||||||
{
|
|
||||||
return $this->repo->getCurrentBranchName();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function delete(): void
|
|
||||||
{
|
|
||||||
system("rm -rf " . escapeshellarg($this->repo->getRepositoryPath()));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getLatestVersion(): string
|
|
||||||
{
|
|
||||||
return self::MASTER_BRANCH_NAME;
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,21 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Service\SnipContent;
|
|
||||||
|
|
||||||
interface SnipContentInterface
|
|
||||||
{
|
|
||||||
public function update(string $snipContents): void;
|
|
||||||
|
|
||||||
public function get(): string;
|
|
||||||
|
|
||||||
/** @return array{id: string, name: string} */
|
|
||||||
public function getVersions(): array;
|
|
||||||
|
|
||||||
public function setVersion(string $version): void;
|
|
||||||
|
|
||||||
public function getCommit(): string;
|
|
||||||
|
|
||||||
public function getLatestVersion(): string;
|
|
||||||
|
|
||||||
public function delete(): void;
|
|
||||||
}
|
|
96
src/Service/SnipContent/SnipContentService.php
Normal file
96
src/Service/SnipContent/SnipContentService.php
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Service\SnipContent;
|
||||||
|
|
||||||
|
use App\Entity\Snip;
|
||||||
|
use App\Entity\SnipContent;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
|
||||||
|
readonly class SnipContentService
|
||||||
|
{
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private EntityManagerInterface $em,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function update(Snip $snip, string $contents, ?string $contentName): void
|
||||||
|
{
|
||||||
|
$parentContent = $snip->activeVersion;
|
||||||
|
if (self::rebuildText($parentContent) === $contents) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new snipContent entity with previous one as parent
|
||||||
|
$content = new SnipContent();
|
||||||
|
$content->text = $contents;
|
||||||
|
$content->snip = $snip;
|
||||||
|
$content->name = $contentName;
|
||||||
|
|
||||||
|
if ($parentContent !== null) {
|
||||||
|
$content->parent = $parentContent;
|
||||||
|
$this->contentToRelative($parentContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->em->persist($content);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
$snip->activeVersion = $content;
|
||||||
|
$this->em->persist($snip);
|
||||||
|
$this->em->flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function rebuildText(?SnipContent $snipContent): string
|
||||||
|
{
|
||||||
|
if ($snipContent === null) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if ($snipContent->text) {
|
||||||
|
return $snipContent->text;
|
||||||
|
}
|
||||||
|
|
||||||
|
$parentContent = $snipContent->parent;
|
||||||
|
if ($parentContent === null && $snipContent->diff === null) {
|
||||||
|
return '---Something went very wrong, cant rebuild the text---';
|
||||||
|
}
|
||||||
|
|
||||||
|
return MyersDiff::rebuildBFromCompact(
|
||||||
|
self::rebuildText($parentContent), $snipContent->diff
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setVersion(Snip $snip, SnipContent $version): void
|
||||||
|
{
|
||||||
|
$activeVersion = $snip->activeVersion;
|
||||||
|
$this->contentToAbsolute($version);
|
||||||
|
$this->contentToRelative($activeVersion);
|
||||||
|
|
||||||
|
$snip->activeVersion = $version;
|
||||||
|
$this->em->persist($snip);
|
||||||
|
$this->em->flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function contentToRelative(SnipContent $content): void
|
||||||
|
{
|
||||||
|
if ($content->text === null || $content->parent === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$contentText = $content->text;
|
||||||
|
$parentText = self::rebuildText($content->parent);
|
||||||
|
$diff = MyersDiff::calculate($parentText, $contentText);
|
||||||
|
$content->diff = $diff;
|
||||||
|
$content->text = null;
|
||||||
|
$this->em->persist($content);
|
||||||
|
$this->em->flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function contentToAbsolute(SnipContent $content): void
|
||||||
|
{
|
||||||
|
if ($content->diff === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$content->text = self::rebuildText($content);
|
||||||
|
$content->diff = null;
|
||||||
|
$this->em->persist($content);
|
||||||
|
$this->em->flush();
|
||||||
|
}
|
||||||
|
}
|
28
src/Service/SnipParser/AbstractParser.php
Normal file
28
src/Service/SnipParser/AbstractParser.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Service\SnipParser;
|
||||||
|
|
||||||
|
abstract class AbstractParser implements ParserInterface
|
||||||
|
{
|
||||||
|
public static function getName(): string
|
||||||
|
{
|
||||||
|
$path = explode('\\', static::class);
|
||||||
|
return strtolower(str_replace('Parser', '', array_pop($path)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function parseRaw(string $content): string
|
||||||
|
{
|
||||||
|
return $content;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function parseView(string $content): string
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return $this->safeParseView($content);
|
||||||
|
} catch (\Exception $exception) {
|
||||||
|
return sprintf('<pre><code>%s</code></pre>', htmlspecialchars($exception->getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract function safeParseView(string $content): string;
|
||||||
|
}
|
30
src/Service/SnipParser/Generic/GenericParser.php
Normal file
30
src/Service/SnipParser/Generic/GenericParser.php
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Service\SnipParser\Generic;
|
||||||
|
|
||||||
|
use App\Service\SnipParser\AbstractParser;
|
||||||
|
use League\Pipeline\PipelineBuilder;
|
||||||
|
|
||||||
|
class GenericParser extends AbstractParser
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly UrlReferenceStage $referenceStage,
|
||||||
|
private readonly IncludeReferenceStage $includeStage,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function safeParseView(string $content): string
|
||||||
|
{
|
||||||
|
$builder = new PipelineBuilder();
|
||||||
|
$pipeline = $builder
|
||||||
|
->add(new HtmlEscapeStage())
|
||||||
|
// ->add(new ReplaceBlocksStage('<pre>', '</pre>', '```'))
|
||||||
|
// ->add(new ReplaceBlocksStage('<code>', '</code>', '``'))
|
||||||
|
->add(new ReplaceStage(PHP_EOL, '<br>'))
|
||||||
|
->add($this->referenceStage)
|
||||||
|
->add($this->includeStage)
|
||||||
|
->build()
|
||||||
|
;
|
||||||
|
|
||||||
|
return $pipeline->process($content);
|
||||||
|
}
|
||||||
|
}
|
@@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Service\SnipParser\Stages;
|
namespace App\Service\SnipParser\Generic;
|
||||||
|
|
||||||
use League\Pipeline\StageInterface;
|
use League\Pipeline\StageInterface;
|
||||||
|
|
59
src/Service/SnipParser/Generic/IncludeReferenceStage.php
Normal file
59
src/Service/SnipParser/Generic/IncludeReferenceStage.php
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Service\SnipParser\Generic;
|
||||||
|
|
||||||
|
use App\Repository\SnipContentRepository;
|
||||||
|
use App\Repository\SnipRepository;
|
||||||
|
use App\Security\Voter\SnipVoter;
|
||||||
|
use App\Service\SnipContent\SnipContentService;
|
||||||
|
use League\Pipeline\StageInterface;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
|
||||||
|
class IncludeReferenceStage implements StageInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
#[Autowire(lazy: true)] private readonly Security $security,
|
||||||
|
#[Autowire(lazy: true)] private readonly SnipRepository $snipRepository,
|
||||||
|
#[Autowire(lazy: true)] private readonly SnipContentRepository $snipContentRepository,
|
||||||
|
#[Autowire(lazy: true)] private readonly GenericParser $pipeline,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(mixed $payload): string
|
||||||
|
{
|
||||||
|
return $this->replaceReferences($payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function replaceReferences(mixed $payload): string
|
||||||
|
{
|
||||||
|
// replaces all references ({{ID}}) with the content of the snip
|
||||||
|
$pattern = '/\{\{([A-Z0-9]+)\}\}/';
|
||||||
|
|
||||||
|
return preg_replace_callback($pattern, function ($matches) {
|
||||||
|
$id = $matches[1];
|
||||||
|
try {
|
||||||
|
$content = $this->snipContentRepository->find($id);
|
||||||
|
} catch (\Exception) {
|
||||||
|
$content = null;
|
||||||
|
}
|
||||||
|
if ($content) {
|
||||||
|
$snip = $content->snip;
|
||||||
|
} else {
|
||||||
|
$snip = $this->snipRepository->find($id);
|
||||||
|
if ($snip) {
|
||||||
|
$content = $this->snipContentRepository->find($snip->activeVersion);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($content === null) {
|
||||||
|
return sprintf('<span title="snip or content not found">%s</span>', $matches[0]);
|
||||||
|
}
|
||||||
|
if (!$this->security->isGranted(SnipVoter::VIEW, $snip)) {
|
||||||
|
return sprintf('<span title="access denied">%s</span>', $matches[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->pipeline->parseView(
|
||||||
|
SnipContentService::rebuildText($content)
|
||||||
|
);
|
||||||
|
}, $payload);
|
||||||
|
}
|
||||||
|
}
|
@@ -1,16 +1,17 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Service\SnipParser\Stages;
|
namespace App\Service\SnipParser\Generic;
|
||||||
|
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
use League\Pipeline\StageInterface;
|
use League\Pipeline\StageInterface;
|
||||||
|
use Tempest\Highlight\Highlighter;
|
||||||
|
|
||||||
class ReplaceBlocksStage implements StageInterface
|
readonly class ReplaceBlocksStage implements StageInterface
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public readonly string $openTag = '<pre><code>',
|
public string $openTag = '<pre><code>',
|
||||||
public readonly string $closeTag = '</code></pre>',
|
public string $closeTag = '</code></pre>',
|
||||||
public readonly string $delimiter = '```'
|
public string $delimiter = '```'
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function __invoke(mixed $payload): string
|
public function __invoke(mixed $payload): string
|
||||||
@@ -26,8 +27,9 @@ 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));
|
||||||
|
|
||||||
return preg_replace_callback($pattern, function ($matches) {
|
$highlighter = new Highlighter()->withGutter();
|
||||||
return $this->openTag . trim($matches[1]) . $this->closeTag;
|
return preg_replace_callback($pattern, function ($matches) use ($highlighter) {
|
||||||
|
return $this->openTag . $highlighter->parse(trim($matches[1]), 'php') . $this->closeTag;
|
||||||
}, $text);
|
}, $text);
|
||||||
}
|
}
|
||||||
}
|
}
|
@@ -1,15 +1,15 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Service\SnipParser\Stages;
|
namespace App\Service\SnipParser\Generic;
|
||||||
|
|
||||||
use League\Pipeline\StageInterface;
|
use League\Pipeline\StageInterface;
|
||||||
|
|
||||||
class ReplaceStage implements StageInterface
|
readonly class ReplaceStage implements StageInterface
|
||||||
{
|
{
|
||||||
// replaces a string with another string
|
// replaces a string with another string
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public readonly string $search,
|
public string $search,
|
||||||
public readonly string $replace,
|
public string $replace,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function __invoke(mixed $payload): string
|
public function __invoke(mixed $payload): string
|
13
src/Service/SnipParser/Generic/UnsafeParser.php
Normal file
13
src/Service/SnipParser/Generic/UnsafeParser.php
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Service\SnipParser\Generic;
|
||||||
|
|
||||||
|
use App\Service\SnipParser\AbstractParser;
|
||||||
|
|
||||||
|
class UnsafeParser extends AbstractParser
|
||||||
|
{
|
||||||
|
public function safeParseView(string $content): string
|
||||||
|
{
|
||||||
|
return $content;
|
||||||
|
}
|
||||||
|
}
|
@@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Service\SnipParser\Stages;
|
namespace App\Service\SnipParser\Generic;
|
||||||
|
|
||||||
use App\Repository\SnipRepository;
|
use App\Repository\SnipRepository;
|
||||||
use App\Security\Voter\SnipVoter;
|
use App\Security\Voter\SnipVoter;
|
||||||
@@ -9,12 +9,12 @@ use Symfony\Bundle\SecurityBundle\Security;
|
|||||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||||
|
|
||||||
class UrlReferenceStage implements StageInterface
|
readonly class UrlReferenceStage implements StageInterface
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
#[Autowire(lazy: true)] private readonly UrlGeneratorInterface $router,
|
#[Autowire(lazy: true)] private UrlGeneratorInterface $router,
|
||||||
#[Autowire(lazy: true)] private readonly Security $security,
|
#[Autowire(lazy: true)] private Security $security,
|
||||||
#[Autowire(lazy: true)] private readonly SnipRepository $snipRepository,
|
#[Autowire(lazy: true)] private SnipRepository $snipRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function __invoke(mixed $payload): string
|
public function __invoke(mixed $payload): string
|
||||||
@@ -36,8 +36,8 @@ 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->getId()]);
|
$url = $this->router->generate('snip_single', ['snip' => $snip->id]);
|
||||||
return sprintf('<a href="%s" title="Owner: %s">#%s</a>', $url, $snip->getCreatedBy(), $snip->getId());
|
return sprintf('<a href="%s">%s</a>', $url, $snip);
|
||||||
}, $payload);
|
}, $payload);
|
||||||
}
|
}
|
||||||
}
|
}
|
16
src/Service/SnipParser/Html/HtmlParser.php
Normal file
16
src/Service/SnipParser/Html/HtmlParser.php
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Service\SnipParser\Html;
|
||||||
|
|
||||||
|
use App\Service\SnipParser\AbstractParser;
|
||||||
|
use Tempest\Highlight\Highlighter;
|
||||||
|
|
||||||
|
class HtmlParser extends AbstractParser
|
||||||
|
{
|
||||||
|
public function safeParseView(string $content): string
|
||||||
|
{
|
||||||
|
$highlighter = new Highlighter()->withGutter();
|
||||||
|
|
||||||
|
return '<pre data-lang="html" class="notranslate">' . $highlighter->parse($content, 'html') . '</pre>';
|
||||||
|
}
|
||||||
|
}
|
81
src/Service/SnipParser/Markdown/MarkdownParser.php
Normal file
81
src/Service/SnipParser/Markdown/MarkdownParser.php
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Service\SnipParser\Markdown;
|
||||||
|
|
||||||
|
use App\Repository\SnipRepository;
|
||||||
|
use App\Service\SnipParser\AbstractParser;
|
||||||
|
use League\CommonMark\Event\DocumentParsedEvent;
|
||||||
|
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\Node\Inline\Text;
|
||||||
|
use League\CommonMark\Node\Query;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
use Symfony\Component\Routing\RouterInterface;
|
||||||
|
use Tempest\Highlight\CommonMark\HighlightExtension;
|
||||||
|
use Tempest\Highlight\Highlighter;
|
||||||
|
|
||||||
|
class MarkdownParser extends AbstractParser
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
#[Autowire(lazy: true)] private readonly RouterInterface $router,
|
||||||
|
#[Autowire(lazy: true)] private readonly SnipRepository $snipRepo,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function safeParseView(string $content): string
|
||||||
|
{
|
||||||
|
$config = [
|
||||||
|
'default_attributes' => [
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function documentParsed(DocumentParsedEvent $event): void
|
||||||
|
{
|
||||||
|
$document = $event->getDocument();
|
||||||
|
|
||||||
|
$linkNodes = new Query()
|
||||||
|
->where(Query::type(Link::class))
|
||||||
|
->findAll($document)
|
||||||
|
;
|
||||||
|
|
||||||
|
/** @var Link $linkNode */
|
||||||
|
foreach ($linkNodes as $linkNode) {
|
||||||
|
$url = $linkNode->getUrl();
|
||||||
|
|
||||||
|
if (!is_numeric($url)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$snip = $this->snipRepo->find($url);
|
||||||
|
if ($snip === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$linkNode->setUrl($this->router->generate('snip_single', [
|
||||||
|
'snip' => $url,
|
||||||
|
]));
|
||||||
|
$textNode = $linkNode->firstChild();
|
||||||
|
if (!$textNode) {
|
||||||
|
$linkNode->appendChild(new Text($snip));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
52
src/Service/SnipParser/ParserFactory.php
Normal file
52
src/Service/SnipParser/ParserFactory.php
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Service\SnipParser;
|
||||||
|
|
||||||
|
use App\Entity\Snip;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\AutowireLocator;
|
||||||
|
use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException;
|
||||||
|
use Symfony\Component\DependencyInjection\ServiceLocator;
|
||||||
|
|
||||||
|
readonly class ParserFactory
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
#[AutowireLocator(ParserInterface::class, defaultIndexMethod: 'getName')]
|
||||||
|
private ServiceLocator $locator
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @template T of ParserInterface
|
||||||
|
*
|
||||||
|
* @param class-string<T> $id
|
||||||
|
*
|
||||||
|
* @return T
|
||||||
|
* @throws ServiceNotFoundException
|
||||||
|
*/
|
||||||
|
public function get(string $id): ParserInterface
|
||||||
|
{
|
||||||
|
return $this->locator->get($id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getBySnip(Snip $snip): ParserInterface
|
||||||
|
{
|
||||||
|
$parser = $snip->parser;
|
||||||
|
if (null === $parser) {
|
||||||
|
throw new ServiceNotFoundException(sprintf('Unknown parser for snip "%s"', $snip->parser));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->get($parser);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return iterable<string, ParserInterface>
|
||||||
|
*/
|
||||||
|
public function getAll(): iterable
|
||||||
|
{
|
||||||
|
return $this->locator->getIterator();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getChoices(): iterable
|
||||||
|
{
|
||||||
|
foreach ($this->getAll() as $parser) yield $parser::getName();
|
||||||
|
}
|
||||||
|
}
|
15
src/Service/SnipParser/ParserInterface.php
Normal file
15
src/Service/SnipParser/ParserInterface.php
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Service\SnipParser;
|
||||||
|
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
|
||||||
|
|
||||||
|
#[AutoconfigureTag]
|
||||||
|
interface ParserInterface
|
||||||
|
{
|
||||||
|
public function parseView(string $content): string;
|
||||||
|
|
||||||
|
public function parseRaw(string $content): string;
|
||||||
|
|
||||||
|
public static function getName(): string;
|
||||||
|
}
|
@@ -1,43 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Service\SnipParser;
|
|
||||||
|
|
||||||
use App\Service\SnipParser\Stages\HtmlEscapeStage;
|
|
||||||
use App\Service\SnipParser\Stages\IncludeReferenceStage;
|
|
||||||
use App\Service\SnipParser\Stages\UrlReferenceStage;
|
|
||||||
use App\Service\SnipParser\Stages\ReplaceBlocksStage;
|
|
||||||
use App\Service\SnipParser\Stages\ReplaceStage;
|
|
||||||
use League\Pipeline\PipelineBuilder;
|
|
||||||
|
|
||||||
class Pipeline
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private readonly UrlReferenceStage $referenceStage,
|
|
||||||
private readonly IncludeReferenceStage $includeStage,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function parse(string $payload): string
|
|
||||||
{
|
|
||||||
$builder = new PipelineBuilder();
|
|
||||||
$pipeline = $builder
|
|
||||||
->add(new HtmlEscapeStage())
|
|
||||||
->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->includeStage)
|
|
||||||
->build()
|
|
||||||
;
|
|
||||||
|
|
||||||
return $pipeline->process($payload);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function clean(string $payload): string
|
|
||||||
{
|
|
||||||
return str_replace(
|
|
||||||
['```', '``'],
|
|
||||||
'',
|
|
||||||
$payload
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,44 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Service\SnipParser\Stages;
|
|
||||||
|
|
||||||
use App\Repository\SnipRepository;
|
|
||||||
use App\Security\Voter\SnipVoter;
|
|
||||||
use App\Service\SnipParser\Pipeline;
|
|
||||||
use App\Service\SnipServiceFactory;
|
|
||||||
use League\Pipeline\StageInterface;
|
|
||||||
use Symfony\Bundle\SecurityBundle\Security;
|
|
||||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
|
||||||
|
|
||||||
class IncludeReferenceStage implements StageInterface
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
#[Autowire(lazy: true)] private readonly Security $security,
|
|
||||||
#[Autowire(lazy: true)] private readonly SnipRepository $snipRepository,
|
|
||||||
#[Autowire(lazy: true)] private readonly SnipServiceFactory $snipServiceFactory,
|
|
||||||
#[Autowire(lazy: true)] private readonly Pipeline $pipeline,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function __invoke(mixed $payload): string
|
|
||||||
{
|
|
||||||
return $this->replaceReferences($payload);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function replaceReferences(mixed $payload): string
|
|
||||||
{
|
|
||||||
// replaces all references (#n) to other snips with links
|
|
||||||
$pattern = '/\{\{(\d+)\}\}/';
|
|
||||||
|
|
||||||
return preg_replace_callback($pattern, function ($matches) {
|
|
||||||
$snip = $this->snipRepository->find($matches[1]);
|
|
||||||
if ($snip === null) {
|
|
||||||
return sprintf('<span title="not found">%s</span>', $matches[0]);
|
|
||||||
}
|
|
||||||
if (!$this->security->isGranted(SnipVoter::VIEW, $snip)) {
|
|
||||||
return sprintf('<span title="access denied">%s</span>', $matches[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->pipeline->parse($this->snipServiceFactory->create($snip)->get());
|
|
||||||
}, $payload);
|
|
||||||
}
|
|
||||||
}
|
|
61
src/Service/SnipParser/Twig/SnipLoader.php
Normal file
61
src/Service/SnipParser/Twig/SnipLoader.php
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Service\SnipParser\Twig;
|
||||||
|
|
||||||
|
use App\Entity\Snip;
|
||||||
|
use App\Repository\SnipRepository;
|
||||||
|
use App\Security\Voter\SnipVoter;
|
||||||
|
use App\Service\SnipContent\SnipContentService;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Twig\Error\LoaderError;
|
||||||
|
use Twig\Loader\LoaderInterface;
|
||||||
|
use Twig\Source;
|
||||||
|
|
||||||
|
class SnipLoader implements LoaderInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly SnipRepository $repository,
|
||||||
|
private readonly Security $security,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function getSourceContext(string $name): Source
|
||||||
|
{
|
||||||
|
return new Source($this->getFromKey($name)->getActiveText(), $name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCacheKey(string $name): string
|
||||||
|
{
|
||||||
|
return $this->getFromKey($name)->activeVersion->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isFresh(string $name, int $time): bool
|
||||||
|
{
|
||||||
|
$this->getFromKey($name);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function exists(string $name): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->getFromKey($name);
|
||||||
|
} catch (LoaderError) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getFromKey(string $key): Snip
|
||||||
|
{
|
||||||
|
$snip = $this->repository->find($key);
|
||||||
|
if (!$snip) {
|
||||||
|
throw new LoaderError(\sprintf('Template "%s" is not defined.', $key));
|
||||||
|
}
|
||||||
|
if (!$this->security->isGranted(SnipVoter::VIEW, $snip)) {
|
||||||
|
throw new LoaderError(\sprintf('You do not have permission to view the template "%s".', $key));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $snip;
|
||||||
|
}
|
||||||
|
}
|
63
src/Service/SnipParser/Twig/SnipTwigExtension.php
Normal file
63
src/Service/SnipParser/Twig/SnipTwigExtension.php
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Service\SnipParser\Twig;
|
||||||
|
|
||||||
|
use App\Dto\SnipFilterRequest;
|
||||||
|
use App\Entity\Snip;
|
||||||
|
use App\Repository\SnipRepository;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
use Symfony\Component\Routing\RouterInterface;
|
||||||
|
use Twig\Extension\AbstractExtension;
|
||||||
|
use Twig\TwigFunction;
|
||||||
|
|
||||||
|
class SnipTwigExtension extends AbstractExtension
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
#[Autowire(lazy: true)] private readonly RouterInterface $router,
|
||||||
|
#[Autowire(lazy: true)] private readonly SnipRepository $snipRepo,
|
||||||
|
#[Autowire(lazy: true)] private readonly Security $security,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function getFunctions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
new TwigFunction('snipPath', $this->snipPath(...)),
|
||||||
|
new TwigFunction('snipLink', $this->snipLink(...), [
|
||||||
|
'is_safe' => ['html'],
|
||||||
|
]),
|
||||||
|
new TwigFunction('snipsByTag', $this->snipsByTag(...)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function snipPath(int $id): string
|
||||||
|
{
|
||||||
|
return $this->router->generate('snip_single', [
|
||||||
|
'snip' => $id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function snipLink(int $id): string
|
||||||
|
{
|
||||||
|
$snip = $this->snipRepo->find($id);
|
||||||
|
if ($snip === null) {
|
||||||
|
throw new \Exception(sprintf('Snip not found with id: %d', $id));
|
||||||
|
}
|
||||||
|
return sprintf('<a class="btn btn-sm btn-primary" href="%s">%s</a>', $this->snipPath($id), $snip);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function snipsByTag(string $tag): array
|
||||||
|
{
|
||||||
|
// Todo: get 'context' user from the snip it is called from
|
||||||
|
$user = $this->security->getUser();
|
||||||
|
if ($user === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$request = new SnipFilterRequest(SnipFilterRequest::VISIBILITY_ALL, tag: $tag);
|
||||||
|
$snips = $this->snipRepo->findByRequest($user, $request);
|
||||||
|
return array_map(fn(Snip $snip) => [
|
||||||
|
'id' => $snip->id,
|
||||||
|
'name' => $snip->name,
|
||||||
|
], $snips);
|
||||||
|
}
|
||||||
|
}
|
30
src/Service/SnipParser/Twig/TwigParser.php
Normal file
30
src/Service/SnipParser/Twig/TwigParser.php
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Service\SnipParser\Twig;
|
||||||
|
|
||||||
|
use App\Service\SnipParser\AbstractParser;
|
||||||
|
use Twig\Environment;
|
||||||
|
use Twig\Loader\ArrayLoader;
|
||||||
|
use Twig\Loader\ChainLoader;
|
||||||
|
|
||||||
|
class TwigParser extends AbstractParser
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly SnipLoader $snipLoader,
|
||||||
|
private readonly SnipTwigExtension $snipTwigExtension,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function safeParseView(string $content): string
|
||||||
|
{
|
||||||
|
$loader = new ChainLoader([
|
||||||
|
new ArrayLoader([
|
||||||
|
'index' => $content,
|
||||||
|
]),
|
||||||
|
$this->snipLoader,
|
||||||
|
]);
|
||||||
|
$twig = new Environment($loader);
|
||||||
|
$twig->addExtension($this->snipTwigExtension);
|
||||||
|
|
||||||
|
return $twig->render('index');
|
||||||
|
}
|
||||||
|
}
|
@@ -3,48 +3,17 @@
|
|||||||
namespace App\Service;
|
namespace App\Service;
|
||||||
|
|
||||||
use App\Entity\Snip;
|
use App\Entity\Snip;
|
||||||
use App\Git\CustomGit;
|
use App\Service\SnipContent\SnipContentService;
|
||||||
use App\Service\SnipContent\SnipContentDB;
|
|
||||||
use App\Service\SnipContent\SnipContentGit;
|
|
||||||
use App\Service\SnipContent\SnipContentInterface;
|
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Symfony\Bundle\SecurityBundle\Security;
|
|
||||||
|
|
||||||
class SnipServiceFactory
|
readonly class SnipServiceFactory
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly string $gitStoragePath,
|
private EntityManagerInterface $em,
|
||||||
private readonly string $storageType,
|
|
||||||
private readonly Security $security,
|
|
||||||
private readonly EntityManagerInterface $em,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function create(Snip $snip): SnipContentInterface
|
public function create(Snip $snip): SnipContentService
|
||||||
{
|
{
|
||||||
return match ($this->storageType) {
|
return new SnipContentService($snip, $this->em);
|
||||||
'git' => $this->createGit($snip),
|
|
||||||
'db' => $this->createDB($snip),
|
|
||||||
default => throw new \Exception('Unknown storage type'),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private function createGit(Snip $snip): SnipContentGit
|
|
||||||
{
|
|
||||||
$git = new CustomGit();
|
|
||||||
$repoPath = sprintf('%s/%s', $this->gitStoragePath, $snip->getId());
|
|
||||||
if (!is_dir($repoPath)) {
|
|
||||||
$repo = $git->init($repoPath);
|
|
||||||
touch(sprintf('%s/.gitignore', $repoPath));
|
|
||||||
$repo->addFile('.gitignore');
|
|
||||||
$repo->commit('Initial commit');
|
|
||||||
} else {
|
|
||||||
$repo = $git->open($repoPath);
|
|
||||||
}
|
|
||||||
return new SnipContentGit($repo, $this->security->getUser());
|
|
||||||
}
|
|
||||||
|
|
||||||
private function createDB(Snip $snip): SnipContentDB
|
|
||||||
{
|
|
||||||
return new SnipContentDB($snip, $this->security->getUser(), $this->em);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
45
src/Twig/Extension/SnipFilterExtension.php
Normal file
45
src/Twig/Extension/SnipFilterExtension.php
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Twig\Extension;
|
||||||
|
|
||||||
|
use App\Dto\SnipFilterRequest;
|
||||||
|
use App\Repository\TagRepository;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Twig\Extension\AbstractExtension;
|
||||||
|
use Twig\TwigFunction;
|
||||||
|
|
||||||
|
class SnipFilterExtension extends AbstractExtension
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly TagRepository $tagRepository,
|
||||||
|
private readonly Security $security,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function getFunctions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
new TwigFunction('snipSortOptions', fn() => [
|
||||||
|
SnipFilterRequest::SORT_NAME,
|
||||||
|
SnipFilterRequest::SORT_DATE,
|
||||||
|
]),
|
||||||
|
new TwigFunction('snipFilterOptions', fn() => [
|
||||||
|
SnipFilterRequest::VISIBILITY_ALL,
|
||||||
|
SnipFilterRequest::VISIBILITY_VISIBLE,
|
||||||
|
SnipFilterRequest::VISIBILITY_HIDDEN,
|
||||||
|
SnipFilterRequest::VISIBILITY_ARCHIVED,
|
||||||
|
]),
|
||||||
|
new TwigFunction('snipTagOptions', fn() => $this->getSnipTagOptions()),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getSnipTagOptions(): array
|
||||||
|
{
|
||||||
|
$tags[SnipFilterRequest::TAG_ALL] = 'All tags';
|
||||||
|
$tags[SnipFilterRequest::TAG_NONE] = 'No tags';
|
||||||
|
foreach ($this->tagRepository->findAllByUser($this->security->getUser()) as $tag) {
|
||||||
|
$tags[(string)$tag] = (string)$tag;
|
||||||
|
}
|
||||||
|
return $tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user