- settings form
- manga upload directory
- ContentSource export/import
This commit is contained in:
Jérémy Guillot
2024-07-25 21:05:09 +02:00
parent 07675fddf1
commit 21b2adfa07
22 changed files with 547 additions and 179 deletions

4
.env
View File

@@ -49,3 +49,7 @@ MERCURE_PUBLIC_URL=https://localhost/.well-known/mercure
# The secret used to sign the JWTs # The secret used to sign the JWTs
MERCURE_JWT_SECRET="Mangarr-JWT-Secret" MERCURE_JWT_SECRET="Mangarr-JWT-Secret"
###< symfony/mercure-bundle ### ###< symfony/mercure-bundle ###
#Custom
MANGA_DATA_PATH=/mnt/c/Users/jerem/Mangas
IMAGE_DATA_PATH=/mnt/c/Users/jerem/MangasImages

View File

@@ -7,13 +7,11 @@ export default class extends Controller {
} }
async saveConfiguration(event) { async saveConfiguration(event) {
console.log('saveConfiguration called');
event.preventDefault(); event.preventDefault();
this.formTarget.submit(); this.formTarget.submit();
} }
async testConfiguration(event) { async testConfiguration(event) {
console.log('testConfiguration called');
event.preventDefault(); event.preventDefault();
const formData = new FormData(this.formTarget); const formData = new FormData(this.formTarget);
const testFormData = new FormData(this.testFormTarget); const testFormData = new FormData(this.testFormTarget);

View File

@@ -0,0 +1,81 @@
import { Controller } from '@hotwired/stimulus';
/*
* The following line makes this controller "lazy": it won't be downloaded until needed
* See https://github.com/symfony/stimulus-bridge#lazy-controllers
*/
/* stimulusFetch: 'lazy' */
export default class extends Controller {
// ...
static targets = ["textarea", "submitButton"]
connect() {
document.addEventListener('openImportModal', this.prepareImportModal.bind(this));
document.addEventListener('openExportModal', this.prepareExportModal.bind(this));
}
disconnect() {
document.removeEventListener('openImportModal', this.prepareImportModal.bind(this));
document.removeEventListener('openExportModal', this.prepareExportModal.bind(this));
}
async prepareExportModal() {
try {
const response = await fetch('/settings/export_scrappers');
const data = await response.json();
this.textareaTarget.value = JSON.stringify(data, null, 2);
this.submitButtonTarget.textContent = 'Copy to Clipboard';
this.submitButtonTarget.dataset.action = 'scrapper-import#copyToClipboard';
this.openModal('Export Scrapper Configurations');
} catch (error) {
console.error('Error:', error);
}
}
prepareImportModal() {
this.textareaTarget.value = '';
this.submitButtonTarget.textContent = 'Import';
this.submitButtonTarget.dataset.action = 'scrapper-import#submitImport';
this.openModal('Import Scrapper Configurations');
}
openModal(title) {
const event = new CustomEvent('openScrapperModal', { detail: { title: title } });
document.dispatchEvent(event);
}
async submitImport() {
const jsonData = this.textareaTarget.value;
try {
const response = await fetch('/settings/import_scrappers', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: jsonData
});
const result = await response.json();
if (response.ok) {
console.log(result.message);
document.dispatchEvent(new CustomEvent('closeScrapperModal'));
window.location.reload();
} else {
console.error(result.error);
}
} catch (error) {
console.error('Error:', error);
}
}
copyToClipboard() {
navigator.clipboard.writeText(this.textareaTarget.value).then(() => {
console.log('Copied to clipboard');
document.dispatchEvent(new CustomEvent('closeScrapperModal'));
}, (err) => {
console.error('Could not copy text: ', err);
});
}
}

View File

@@ -99,6 +99,16 @@ export default class extends Controller {
document.dispatchEvent(event); document.dispatchEvent(event);
} }
openImportModal() {
const importEvent = new CustomEvent('openImportModal');
document.dispatchEvent(importEvent);
}
openExportModal() {
const exportEvent = new CustomEvent('openExportModal');
document.dispatchEvent(exportEvent);
}
deleteMangas() { deleteMangas() {
console.log("Deleting mangas..."); console.log("Deleting mangas...");
} }
@@ -145,6 +155,7 @@ export default class extends Controller {
expandAll() { expandAll() {
console.log("Expanding all..."); console.log("Expanding all...");
} }
changeView(event) { changeView(event) {
event.preventDefault(); event.preventDefault();
const viewOption = event.currentTarget.dataset.view; const viewOption = event.currentTarget.dataset.view;

View File

@@ -20,6 +20,8 @@ services:
volumes: volumes:
- caddy_data:/data - caddy_data:/data
- caddy_config:/config - caddy_config:/config
- ${MANGA_DATA_PATH:-~/Mangas}:/manga_data
- ${IMAGE_DATA_PATH:-~/MangaImages}:/image_data
ports: ports:
# HTTP # HTTP
- target: 80 - target: 80

View File

@@ -23,17 +23,14 @@ final class Version20240603161848 extends AbstractMigration
$this->addSql('CREATE SEQUENCE api_token_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); $this->addSql('CREATE SEQUENCE api_token_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
$this->addSql('CREATE SEQUENCE chapter_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); $this->addSql('CREATE SEQUENCE chapter_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
$this->addSql('CREATE SEQUENCE manga_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); $this->addSql('CREATE SEQUENCE manga_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
$this->addSql('CREATE SEQUENCE page_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
$this->addSql('CREATE SEQUENCE source_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); $this->addSql('CREATE SEQUENCE source_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
$this->addSql('CREATE SEQUENCE "user_id_seq" INCREMENT BY 1 MINVALUE 1 START 1'); $this->addSql('CREATE SEQUENCE "user_id_seq" INCREMENT BY 1 MINVALUE 1 START 1');
$this->addSql('CREATE TABLE api_token (id INT NOT NULL, owned_by_id INT NOT NULL, expires_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, token VARCHAR(68) NOT NULL, scopes JSON NOT NULL, PRIMARY KEY(id))'); $this->addSql('CREATE TABLE api_token (id INT NOT NULL, owned_by_id INT NOT NULL, expires_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, token VARCHAR(68) NOT NULL, scopes JSON NOT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE INDEX IDX_7BA2F5EB5E70BCD7 ON api_token (owned_by_id)'); $this->addSql('CREATE INDEX IDX_7BA2F5EB5E70BCD7 ON api_token (owned_by_id)');
$this->addSql('COMMENT ON COLUMN api_token.expires_at IS \'(DC2Type:datetime_immutable)\''); $this->addSql('COMMENT ON COLUMN api_token.expires_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('CREATE TABLE chapter (id INT NOT NULL, manga_id INT NOT NULL, number DOUBLE PRECISION NOT NULL, pages JSON NOT NULL, PRIMARY KEY(id))'); $this->addSql('CREATE TABLE chapter (id INT NOT NULL, manga_id INT NOT NULL, number DOUBLE PRECISION NOT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE INDEX IDX_F981B52E7B6461 ON chapter (manga_id)'); $this->addSql('CREATE INDEX IDX_F981B52E7B6461 ON chapter (manga_id)');
$this->addSql('CREATE TABLE manga (id INT NOT NULL, title VARCHAR(255) NOT NULL, slug VARCHAR(255) NOT NULL, PRIMARY KEY(id))'); $this->addSql('CREATE TABLE manga (id INT NOT NULL, title VARCHAR(255) NOT NULL, slug VARCHAR(255) NOT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE TABLE page (id INT NOT NULL, chapter_id INT NOT NULL, number INT NOT NULL, image_url VARCHAR(255) NOT NULL, image_local_url VARCHAR(255) NOT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE INDEX IDX_140AB620579F4768 ON page (chapter_id)');
$this->addSql('CREATE TABLE source (id INT NOT NULL, name VARCHAR(255) DEFAULT NULL, description TEXT DEFAULT NULL, base_url VARCHAR(255) NOT NULL, scrapping_parameters JSON DEFAULT NULL, is_active BOOLEAN NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))'); $this->addSql('CREATE TABLE source (id INT NOT NULL, name VARCHAR(255) DEFAULT NULL, description TEXT DEFAULT NULL, base_url VARCHAR(255) NOT NULL, scrapping_parameters JSON DEFAULT NULL, is_active BOOLEAN NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))');
$this->addSql('COMMENT ON COLUMN source.created_at IS \'(DC2Type:datetime_immutable)\''); $this->addSql('COMMENT ON COLUMN source.created_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN source.updated_at IS \'(DC2Type:datetime_immutable)\''); $this->addSql('COMMENT ON COLUMN source.updated_at IS \'(DC2Type:datetime_immutable)\'');
@@ -41,7 +38,6 @@ final class Version20240603161848 extends AbstractMigration
$this->addSql('CREATE UNIQUE INDEX UNIQ_8D93D649E7927C74 ON "user" (email)'); $this->addSql('CREATE UNIQUE INDEX UNIQ_8D93D649E7927C74 ON "user" (email)');
$this->addSql('ALTER TABLE api_token ADD CONSTRAINT FK_7BA2F5EB5E70BCD7 FOREIGN KEY (owned_by_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE api_token ADD CONSTRAINT FK_7BA2F5EB5E70BCD7 FOREIGN KEY (owned_by_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE chapter ADD CONSTRAINT FK_F981B52E7B6461 FOREIGN KEY (manga_id) REFERENCES manga (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE chapter ADD CONSTRAINT FK_F981B52E7B6461 FOREIGN KEY (manga_id) REFERENCES manga (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE page ADD CONSTRAINT FK_140AB620579F4768 FOREIGN KEY (chapter_id) REFERENCES chapter (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
} }
public function down(Schema $schema): void public function down(Schema $schema): void
@@ -51,16 +47,13 @@ final class Version20240603161848 extends AbstractMigration
$this->addSql('DROP SEQUENCE api_token_id_seq CASCADE'); $this->addSql('DROP SEQUENCE api_token_id_seq CASCADE');
$this->addSql('DROP SEQUENCE chapter_id_seq CASCADE'); $this->addSql('DROP SEQUENCE chapter_id_seq CASCADE');
$this->addSql('DROP SEQUENCE manga_id_seq CASCADE'); $this->addSql('DROP SEQUENCE manga_id_seq CASCADE');
$this->addSql('DROP SEQUENCE page_id_seq CASCADE');
$this->addSql('DROP SEQUENCE source_id_seq CASCADE'); $this->addSql('DROP SEQUENCE source_id_seq CASCADE');
$this->addSql('DROP SEQUENCE "user_id_seq" CASCADE'); $this->addSql('DROP SEQUENCE "user_id_seq" CASCADE');
$this->addSql('ALTER TABLE api_token DROP CONSTRAINT FK_7BA2F5EB5E70BCD7'); $this->addSql('ALTER TABLE api_token DROP CONSTRAINT FK_7BA2F5EB5E70BCD7');
$this->addSql('ALTER TABLE chapter DROP CONSTRAINT FK_F981B52E7B6461'); $this->addSql('ALTER TABLE chapter DROP CONSTRAINT FK_F981B52E7B6461');
$this->addSql('ALTER TABLE page DROP CONSTRAINT FK_140AB620579F4768');
$this->addSql('DROP TABLE api_token'); $this->addSql('DROP TABLE api_token');
$this->addSql('DROP TABLE chapter'); $this->addSql('DROP TABLE chapter');
$this->addSql('DROP TABLE manga'); $this->addSql('DROP TABLE manga');
$this->addSql('DROP TABLE page');
$this->addSql('DROP TABLE source'); $this->addSql('DROP TABLE source');
$this->addSql('DROP TABLE "user"'); $this->addSql('DROP TABLE "user"');
} }

View File

@@ -0,0 +1,34 @@
<?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 Version20240724164344 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('CREATE SEQUENCE app_settings_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
$this->addSql('CREATE TABLE app_settings (id INT NOT NULL, manga_directory VARCHAR(255) DEFAULT NULL, image_directory VARCHAR(255) DEFAULT NULL, PRIMARY KEY(id))');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA public');
$this->addSql('DROP SEQUENCE app_settings_id_seq CASCADE');
$this->addSql('DROP TABLE app_settings');
}
}

View File

@@ -222,6 +222,9 @@ class MangaController extends AbstractController
} }
/**
* @throws GuzzleException
*/
#[Route('/addManga', name: 'app_manga_add')] #[Route('/addManga', name: 'app_manga_add')]
public function addManga(Request $request): Response public function addManga(Request $request): Response
{ {
@@ -248,8 +251,8 @@ class MangaController extends AbstractController
$imageUrls = $this->processAndSaveImage($imageUrl); $imageUrls = $this->processAndSaveImage($imageUrl);
$manga->setImageUrl($imageUrls['full']); $manga->setImageUrl($imageUrls['full']);
$manga->setThumbnailUrl($imageUrls['thumbnail']); $manga->setThumbnailUrl($imageUrls['thumbnail']);
} catch (\Exception $e) { } catch (\Exception|GuzzleException $e) {
// Gérer l'exception (par exemple, logger l'erreur) throw $e;
} }
$mergedChapters = $this->mangadexProvider->addAllChaptersToManga($manga); $mergedChapters = $this->mangadexProvider->addAllChaptersToManga($manga);

View File

@@ -3,7 +3,10 @@
namespace App\Controller; namespace App\Controller;
use App\Entity\ContentSource; use App\Entity\ContentSource;
use App\Form\AppSettingsType;
use App\Form\ContentSourceType; use App\Form\ContentSourceType;
use App\Manager\AppSettingsManager;
use App\Manager\Toolbar\Factory\ToolbarFactory;
use App\Repository\ContentSourceRepository; use App\Repository\ContentSourceRepository;
use App\Service\NotificationService; use App\Service\NotificationService;
@@ -21,7 +24,8 @@ class SettingsController extends AbstractController
public function __construct( public function __construct(
private MangaScraperService $mangaScraperService, private MangaScraperService $mangaScraperService,
private EntityManagerInterface $entityManager, private EntityManagerInterface $entityManager,
private NotificationService $notificationService private NotificationService $notificationService,
private ContentSourceRepository $contentSourceRepository
) )
{ {
@@ -44,20 +48,34 @@ class SettingsController extends AbstractController
} }
#[Route('/settings/folders', name: 'app_settings_folders')] #[Route('/settings/folders', name: 'app_settings_folders')]
public function folders(): Response public function folders(Request $request, AppSettingsManager $settingsManager): Response
{ {
return $this->render('settings/index.html.twig', [ $currentSettings = $settingsManager->getSettings();
'controller_name' => 'SettingsController',
$form = $this->createForm(AppSettingsType::class, $currentSettings);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$newSettings = $form->getData();
$settingsManager->updateSettings($newSettings);
$this->notificationService->sendUpdate(['status' => 'success', 'message' => 'Settings updated successfully.']);
return $this->json(['success' => true]);
}
return $this->render('settings/folders.html.twig', [
'form' => $form->createView(),
]); ]);
} }
#[Route('/settings/scrappers/list', name: 'app_settings_scrappers_list')] #[Route('/settings/scrappers/list', name: 'app_settings_scrappers_list')]
public function list(ContentSourceRepository $repository): Response public function list(ContentSourceRepository $repository, ToolbarFactory $toolbarFactory): Response
{ {
$contentSources = $repository->findAll(); $contentSources = $repository->findAll();
return $this->render('settings/scrapper_list.html.twig', [ return $this->render('settings/scrapper_list.html.twig', [
'contentSources' => $contentSources, 'contentSources' => $contentSources,
'toolbar' => $toolbarFactory->createToolbar('scraper_list')->getGroups(),
]); ]);
} }
@@ -137,4 +155,51 @@ class SettingsController extends AbstractController
'controller_name' => 'SettingsController', 'controller_name' => 'SettingsController',
]); ]);
} }
#[Route('/settings/export_scrappers', name: 'app_settings_scrappers_export', methods: ['GET'])]
public function exportScrappers(): JsonResponse
{
$contentSources = $this->contentSourceRepository->findAll();
$data = [];
foreach ($contentSources as $source) {
$data[] = [
'baseUrl' => $source->getBaseUrl(),
'imageSelector' => $source->getImageSelector(),
'nextPageSelector' => $source->getNextPageSelector(),
'chapterUrlFormat' => $source->getChapterUrlFormat(),
'scrapingType' => $source->getScrapingType(),
'chapterSelector' => $source->getChapterSelector(), //TODO à renommer en chapterListSelector
];
}
return new JsonResponse($data);
}
#[Route('/settings/import_scrappers', name: 'app_settings_scrappers_import', methods: ['POST'])]
public function importScrappers(Request $request): JsonResponse
{
$content = $request->getContent();
$data = json_decode($content, true);
if (json_last_error() !== JSON_ERROR_NONE) {
$this->notificationService->sendUpdate(['status' => 'error', 'message' => 'Invalid JSON data']);
return new JsonResponse(['error' => 'Invalid JSON data'], 400);
}
foreach ($data as $sourceData) {
$contentSource = new ContentSource();
$contentSource->setBaseUrl($sourceData['baseUrl']);
$contentSource->setImageSelector($sourceData['imageSelector']);
$contentSource->setNextPageSelector($sourceData['nextPageSelector']);
$contentSource->setChapterUrlFormat($sourceData['chapterUrlFormat']);
$contentSource->setScrapingType($sourceData['scrapingType']);
$this->entityManager->persist($contentSource);
}
$this->entityManager->flush();
return new JsonResponse(['message' => 'Content sources imported successfully']);
}
} }

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Entity;
use App\Repository\AppSettingsRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: AppSettingsRepository::class)]
class AppSettings
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $MangaDirectory = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $ImageDirectory = null;
public function getId(): ?int
{
return $this->id;
}
public function getMangaDirectory(): ?string
{
return $this->MangaDirectory;
}
public function setMangaDirectory(?string $MangaDirectory): static
{
$this->MangaDirectory = $MangaDirectory;
return $this;
}
public function getImageDirectory(): ?string
{
return $this->ImageDirectory;
}
public function setImageDirectory(?string $ImageDirectory): static
{
$this->ImageDirectory = $ImageDirectory;
return $this;
}
}

View File

@@ -20,16 +20,10 @@ class Chapter
#[ORM\Column] #[ORM\Column]
private ?float $number = null; private ?float $number = null;
#[ORM\Column]
private array $pages = [];
#[ORM\ManyToOne(inversedBy: 'chapters')] #[ORM\ManyToOne(inversedBy: 'chapters')]
#[ORM\JoinColumn(nullable: false)] #[ORM\JoinColumn(nullable: false)]
private ?Manga $manga = null; private ?Manga $manga = null;
#[ORM\OneToMany(mappedBy: 'chapter', targetEntity: Page::class, orphanRemoval: true)]
private Collection $pagesLink;
#[ORM\Column(nullable: true)] #[ORM\Column(nullable: true)]
private ?int $volume = null; private ?int $volume = null;
@@ -45,12 +39,11 @@ class Chapter
#[ORM\Column(length: 255, nullable: true)] #[ORM\Column(length: 255, nullable: true)]
private ?string $cbzPath = null; private ?string $cbzPath = null;
#[ORM\Column] #[ORM\Column(type: 'boolean', options: ['default' => true])]
private ?bool $visible = true; private ?bool $visible = true;
public function __construct() public function __construct()
{ {
$this->pagesLink = new ArrayCollection();
} }
public function getId(): ?int public function getId(): ?int
@@ -70,18 +63,6 @@ class Chapter
return $this; return $this;
} }
public function getPages(): array
{
return $this->pages;
}
public function setPages(array $pages): self
{
$this->pages = $pages;
return $this;
}
public function getManga(): ?Manga public function getManga(): ?Manga
{ {
return $this->manga; return $this->manga;
@@ -94,50 +75,6 @@ class Chapter
return $this; return $this;
} }
/**
* @return Collection<int, Page>
*/
public function getPagesLink(): Collection
{
return $this->pagesLink;
}
public function addPagesLink(Page $pagesLink): self
{
if (!$this->pagesLink->contains($pagesLink)) {
$this->pagesLink->add($pagesLink);
$pagesLink->setChapter($this);
}
return $this;
}
public function removePagesLink(Page $pagesLink): self
{
if ($this->pagesLink->removeElement($pagesLink)) {
// set the owning side to null (unless already changed)
if ($pagesLink->getChapter() === $this) {
$pagesLink->setChapter(null);
}
}
return $this;
}
public function getPageByNumber(int $number): ?Page
{
/**
* @var Page $page
*/
foreach ($this->pagesLink as $page) {
if ($page->getNumber() === $number) {
return $page;
}
}
return null;
}
public function getVolume(): ?int public function getVolume(): ?int
{ {
return $this->volume; return $this->volume;

View File

@@ -1,81 +0,0 @@
<?php
namespace App\Entity;
use App\Repository\PageRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: PageRepository::class)]
class Page
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column]
private ?int $number = null;
#[ORM\Column(length: 255)]
private ?string $imageUrl = null;
#[ORM\ManyToOne(inversedBy: 'pagesLink')]
#[ORM\JoinColumn(nullable: false)]
private ?Chapter $chapter = null;
#[ORM\Column(length: 255)]
private ?string $imageLocalUrl = null;
public function getId(): ?int
{
return $this->id;
}
public function getNumber(): ?int
{
return $this->number;
}
public function setNumber(int $number): self
{
$this->number = $number;
return $this;
}
public function getImageUrl(): ?string
{
return $this->imageUrl;
}
public function setImageUrl(string $imageUrl): self
{
$this->imageUrl = $imageUrl;
return $this;
}
public function getChapter(): ?Chapter
{
return $this->chapter;
}
public function setChapter(?Chapter $chapter): self
{
$this->chapter = $chapter;
return $this;
}
public function getImageLocalUrl(): ?string
{
return $this->imageLocalUrl;
}
public function setImageLocalUrl(string $imageLocalUrl): self
{
$this->imageLocalUrl = $imageLocalUrl;
return $this;
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Form;
use App\Entity\AppSettings;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class AppSettingsType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('mangaDirectory', TextType::class, [
'label' => 'Manga Directory',
])
->add('imageDirectory', TextType::class, [
'label' => 'Image Directory',
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => AppSettings::class,
]);
}
}

View File

@@ -22,7 +22,7 @@ class ContentSourceType extends AbstractType
'label' => 'Image Selector', 'label' => 'Image Selector',
]) ])
->add('chapterUrlFormat', TextType::class, [ ->add('chapterUrlFormat', TextType::class, [
'label' => 'Chapter URL Format', 'label' => 'Chapter URL Format ({slug}, {chapterNumber})',
]) ])
->add('nextPageSelector', TextType::class, [ ->add('nextPageSelector', TextType::class, [
'label' => 'Next Page Selector (let empty if vertical reader)', 'label' => 'Next Page Selector (let empty if vertical reader)',

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Manager;
use App\Entity\AppSettings;
use Doctrine\ORM\EntityManagerInterface;
class AppSettingsManager
{
private const string DEFAULT_MANGA_DIRECTORY = '/manga_data';
private const string DEFAULT_IMAGE_DIRECTORY = '/image_data';
public function __construct(private readonly EntityManagerInterface $entityManager)
{
}
public function getSettings(): AppSettings
{
$settings = $this->entityManager->getRepository(AppSettings::class)->findOneBy([]);
if (!$settings) {
$settings = $this->createDefaultSettings();
}
return $settings;
}
public function updateSettings(AppSettings $newSettings): void
{
$settings = $this->entityManager->getRepository(AppSettings::class)->findOneBy([]);
if (!$settings) {
$settings = new AppSettings();
}
$settings->setMangaDirectory($newSettings->getMangaDirectory());
$settings->setImageDirectory($newSettings->getImageDirectory());
$this->entityManager->persist($settings);
$this->entityManager->flush();
}
private function createDefaultSettings(): AppSettings
{
$settings = new AppSettings();
$settings->setMangaDirectory(self::DEFAULT_MANGA_DIRECTORY);
$settings->setImageDirectory(self::DEFAULT_IMAGE_DIRECTORY);
$this->entityManager->persist($settings);
$this->entityManager->flush();
return $settings;
}
}

View File

@@ -11,17 +11,48 @@ class FileSystemManager
private const string UPLOADS_DIRECTORY = 'public/tmp'; private const string UPLOADS_DIRECTORY = 'public/tmp';
private const string IMAGES_DIRECTORY = 'public/images'; private const string IMAGES_DIRECTORY = 'public/images';
private string $mangaDirectory;
private string $imageDirectory;
public function __construct( public function __construct(
private readonly string $projectDir, private readonly string $projectDir,
private readonly Filesystem $filesystem, private readonly Filesystem $filesystem,
private readonly SluggerInterface $slugger private readonly SluggerInterface $slugger,
) { private readonly AppSettingsManager $appSettingsManager
)
{
$this->loadSettings();
}
private function loadSettings(): void
{
$settings = $this->appSettingsManager->getSettings();
$this->mangaDirectory = $settings->getMangaDirectory();
$this->imageDirectory = $settings->getImageDirectory();
}
public function getMangaDirectory(): string
{
return $this->mangaDirectory;
}
public function getImageDirectory(): string
{
return $this->imageDirectory;
}
public function getImagePath(string $subDir = ''): string
{
if(!$this->filesystem->exists($this->projectDir. '/' . self::IMAGES_DIRECTORY . ($subDir ? "/$subDir" : ''))) {
$this->filesystem->mkdir($this->projectDir. '/' . self::IMAGES_DIRECTORY . ($subDir ? "/$subDir" : ''), 0755);
}
return $this->projectDir. '/' . self::IMAGES_DIRECTORY . ($subDir ? "/$subDir" : '');
} }
public function createMangaDirectory(string $mangaSlug, ?int $year): string public function createMangaDirectory(string $mangaSlug, ?int $year): string
{ {
$year = $year ?? 'unknown'; $year = $year ?? 'unknown';
$directoryPath = $this->projectDir . '/' . self::CBZ_DIRECTORY . '/' . ucfirst($mangaSlug) . " ($year)"; $directoryPath = $this->mangaDirectory . '/' . ucfirst($mangaSlug) . " ($year)";
$this->filesystem->mkdir($directoryPath, 0755); $this->filesystem->mkdir($directoryPath, 0755);
return $directoryPath; return $directoryPath;
} }
@@ -76,11 +107,6 @@ class FileSystemManager
return $safeFilename . '-' . uniqid() . '.' . pathinfo($originalFilename, PATHINFO_EXTENSION); return $safeFilename . '-' . uniqid() . '.' . pathinfo($originalFilename, PATHINFO_EXTENSION);
} }
public function getImagePath(string $subDir = ''): string
{
return $this->projectDir . '/' . self::IMAGES_DIRECTORY . ($subDir ? "/$subDir" : '');
}
public function generateUniqueImageFilename(string $originalFilename): string public function generateUniqueImageFilename(string $originalFilename): string
{ {
$safeFilename = $this->slugger->slug(pathinfo($originalFilename, PATHINFO_FILENAME)); $safeFilename = $this->slugger->slug(pathinfo($originalFilename, PATHINFO_FILENAME));

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Manager\Toolbar\Definition;
use App\Manager\Toolbar\Element\ToolbarButton;
use App\Manager\Toolbar\Element\ToolbarDivider;
class ScraperListToolbar extends Toolbar
{
public function __construct(array $contextData = [])
{
$this->addToRightGroup(new ToolbarButton('file-import', 'Import Json', 'toolbar#openImportModal'))
->addToRightGroup(new ToolbarDivider())
->addToRightGroup(new ToolbarButton('file-export', 'Export Json', 'toolbar#openExportModal'));
}
}

View File

@@ -5,6 +5,7 @@ namespace App\Manager\Toolbar\Factory;
use App\Manager\Toolbar\Definition\ActivityToolbar; use App\Manager\Toolbar\Definition\ActivityToolbar;
use App\Manager\Toolbar\Definition\ChapterListToolbar; use App\Manager\Toolbar\Definition\ChapterListToolbar;
use App\Manager\Toolbar\Definition\MangaListToolbar; use App\Manager\Toolbar\Definition\MangaListToolbar;
use App\Manager\Toolbar\Definition\ScraperListToolbar;
use App\Manager\Toolbar\Definition\Toolbar; use App\Manager\Toolbar\Definition\Toolbar;
class ToolbarFactory class ToolbarFactory
@@ -15,6 +16,7 @@ class ToolbarFactory
'manga_list' => new MangaListToolbar(), 'manga_list' => new MangaListToolbar(),
'chapter_list' => new ChapterListToolbar($context), 'chapter_list' => new ChapterListToolbar($context),
'activity' => new ActivityToolbar($context), 'activity' => new ActivityToolbar($context),
'scraper_list' => new ScraperListToolbar($context),
default => throw new \InvalidArgumentException("Unknown toolbar type: $type"), default => throw new \InvalidArgumentException("Unknown toolbar type: $type"),
}; };
} }

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Repository;
use App\Entity\AppSettings;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<AppSettings>
*
* @method AppSettings|null find($id, $lockMode = null, $lockVersion = null)
* @method AppSettings|null findOneBy(array $criteria, array $orderBy = null)
* @method AppSettings[] findAll()
* @method AppSettings[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
class AppSettingsRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, AppSettings::class);
}
// /**
// * @return AppSettings[] Returns an array of AppSettings objects
// */
// public function findByExampleField($value): array
// {
// return $this->createQueryBuilder('a')
// ->andWhere('a.exampleField = :val')
// ->setParameter('val', $value)
// ->orderBy('a.id', 'ASC')
// ->setMaxResults(10)
// ->getQuery()
// ->getResult()
// ;
// }
// public function findOneBySomeField($value): ?AppSettings
// {
// return $this->createQueryBuilder('a')
// ->andWhere('a.exampleField = :val')
// ->setParameter('val', $value)
// ->getQuery()
// ->getOneOrNullResult()
// ;
// }
}

View File

@@ -15,7 +15,7 @@
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span> <span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span>
{# Modal panel #} {# Modal panel #}
<div class="inline-block align-bottom bg-white rounded-sm text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle {{ modalClass|default('sm:max-w-lg') }} sm:w-full"> <div {% if stimulus is defined %} data-controller="{{ stimulus }}" {% endif %} class="inline-block align-bottom bg-white rounded-sm text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle {{ modalClass|default('sm:max-w-lg') }} sm:w-full">
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"> <div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<h3 class="text-lg leading-6 font-medium text-gray-900" id="modal-title"> <h3 class="text-lg leading-6 font-medium text-gray-900" id="modal-title">
{{ title }} {{ title }}

View File

@@ -0,0 +1,62 @@
{% extends 'base.html.twig' %}
{% block title %}Application Settings{% endblock %}
{% block body %}
<div class="container mx-auto p-4">
<div class="bg-white shadow-lg rounded-sm overflow-hidden">
<div class="bg-gray-800 text-white p-4">
<h1 class="text-2xl font-bold">
<i class="fas fa-cog mr-2"></i>Application Settings
</h1>
</div>
<div class="p-6">
{{ form_start(form, {'attr': {'class': 'space-y-6'}}) }}
<div class="mb-4">
{{ form_label(form.mangaDirectory, 'Manga Directory', {'label_attr': {'class': 'block text-sm font-medium text-gray-700 mb-2'}}) }}
{{ form_widget(form.mangaDirectory, {'attr': {
'class': 'mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-green-500 focus:border-green-500 sm:text-sm',
'placeholder': '/path/to/manga/directory'
}}) }}
{{ form_errors(form.mangaDirectory) }}
</div>
<div class="mb-4">
{{ form_label(form.imageDirectory, 'Image Directory', {'label_attr': {'class': 'block text-sm font-medium text-gray-700 mb-2'}}) }}
{{ form_widget(form.imageDirectory, {'attr': {
'class': 'mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-green-500 focus:border-green-500 sm:text-sm',
'placeholder': '/path/to/image/directory'
}}) }}
{{ form_errors(form.imageDirectory) }}
</div>
<div class="flex items-center justify-between mt-6">
<button type="submit" class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500">
Save Settings
</button>
</div>
{{ form_end(form) }}
</div>
</div>
<div class="mt-8 bg-white shadow-lg rounded-sm overflow-hidden">
<div class="bg-gray-800 text-white p-4">
<h2 class="text-xl font-bold">
<i class="fas fa-info-circle mr-2"></i>Current Settings
</h2>
</div>
<div class="p-6">
<dl class="grid grid-cols-1 gap-x-4 gap-y-8 sm:grid-cols-2">
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-gray-500">Manga Directory</dt>
<dd class="mt-1 text-sm text-gray-900">{{ form.mangaDirectory.vars.value }}</dd>
</div>
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-gray-500">Image Directory</dt>
<dd class="mt-1 text-sm text-gray-900">{{ form.imageDirectory.vars.value }}</dd>
</div>
</dl>
</div>
</div>
</div>
{% endblock %}

View File

@@ -1,5 +1,9 @@
{% extends 'base.html.twig' %} {% extends 'base.html.twig' %}
{% block toolbar %}
{% if toolbar is defined %}
<twig:Toolbar toolbar="{{ toolbar }}"/>
{% endif %}
{% endblock %}
{% block title %}Scrapper Configurations{% endblock %} {% block title %}Scrapper Configurations{% endblock %}
{% block body %} {% block body %}
@@ -8,13 +12,15 @@
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{% for contentSource in contentSources %} {% for contentSource in contentSources %}
<div class="relative flex flex-col justify-between bg-white rounded-sm border border-gray-200 shadow-md hover:shadow-lg transition-shadow duration-300 h-full group"> <div
class="relative flex flex-col justify-between bg-white rounded-sm border border-gray-200 shadow-md hover:shadow-lg transition-shadow duration-300 h-full group">
<div class="p-4"> <div class="p-4">
<div class="flex flex-row items-center justify-between mb-2"> <div class="flex flex-row items-center justify-between mb-2">
<h5 class="text-xl tracking-tight text-gray-900 truncate flex-grow"> <h5 class="text-xl tracking-tight text-gray-900 truncate flex-grow">
{{ contentSource.baseUrl|replace({'http://': '', 'https://': ''})|trim('/', 'right') }} {{ contentSource.baseUrl|replace({'http://': '', 'https://': ''})|trim('/', 'right') }}
</h5> </h5>
<a href="{{ contentSource.baseUrl }}" target="_blank" rel="noopener noreferrer" class="text-gray-400 hover:text-green-600 ml-2 z-10"> <a href="{{ contentSource.baseUrl }}" target="_blank" rel="noopener noreferrer"
class="text-gray-400 hover:text-green-600 ml-2 z-10">
<i class="fas fa-external-link-alt"></i> <i class="fas fa-external-link-alt"></i>
</a> </a>
</div> </div>
@@ -29,13 +35,15 @@
</span> </span>
</div> </div>
</div> </div>
<a href="{{ path('app_settings_scrappers', {'id': contentSource.id}) }}" class="absolute inset-0 z-0"> <a href="{{ path('app_settings_scrappers', {'id': contentSource.id}) }}"
class="absolute inset-0 z-0">
<span class="sr-only">Edit configuration</span> <span class="sr-only">Edit configuration</span>
</a> </a>
</div> </div>
{% endfor %} {% endfor %}
<a href="{{ path('app_settings_scrappers') }}" class="block p-6 bg-white rounded-sm border border-gray-200 shadow-md hover:bg-gray-100 flex items-center justify-center h-full"> <a href="{{ path('app_settings_scrappers') }}"
class="block p-6 bg-white rounded-sm border border-gray-200 shadow-md hover:bg-gray-100 flex items-center justify-center h-full">
<div class="text-center"> <div class="text-center">
<i class="fas fa-plus text-4xl text-gray-400 mb-2"></i> <i class="fas fa-plus text-4xl text-gray-400 mb-2"></i>
<p class="text-gray-600">Add New Configuration</p> <p class="text-gray-600">Add New Configuration</p>
@@ -43,4 +51,31 @@
</a> </a>
</div> </div>
</div> </div>
<twig:Modal
openTrigger="openScrapperModal"
closeTrigger="closeScrapperModal"
title="Import/Export Scrapper Configurations"
modalClass="w-full max-w-4xl"
stimulus="scrapper_import"
>
{% block content %}
<div {{ stimulus_controller('scrapper_import') }}>
<div class="space-y-4 overflow-y-auto px-4">
<textarea data-scrapper-import-target="textarea" rows="15"
class="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-green-500"></textarea>
</div>
<button type="button" data-action="modal#close"
class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm">
Cancel
</button>
<button data-scrapper-import-target="submitButton" type="button"
class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-green-600 text-base font-medium text-white hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 sm:ml-3 sm:w-auto sm:text-sm">
Submit
</button>
</div>
{% endblock %}
{% block footer %}
{% endblock %}
</twig:Modal>
{% endblock %} {% endblock %}