diff --git a/assets/vue/app/domain/setting/application/store/contentSourceStore.js b/assets/vue/app/domain/setting/application/store/contentSourceStore.js new file mode 100644 index 0000000..4cdabdd --- /dev/null +++ b/assets/vue/app/domain/setting/application/store/contentSourceStore.js @@ -0,0 +1,186 @@ +import { defineStore } from 'pinia'; +import { ApiContentSourceRepository } from '../../infrastructure/api/apiContentSourceRepository'; + +const contentSourceRepository = new ApiContentSourceRepository(); + +export const useContentSourceStore = defineStore('contentSource', { + state: () => ({ + // Collection state + sources: [], + loadingSources: false, + sourcesError: null, + + // Current source state + currentSource: null, + loadingCurrentSource: false, + currentSourceError: null, + + // Create/Update state + saving: false, + saveError: null, + + // Import/Export state + importing: false, + exporting: false, + importError: null, + exportError: null + }), + + getters: { + getSourceById: (state) => (id) => { + return state.sources.find(source => source.id === id); + }, + + getSourcesByType: (state) => (scrapingType) => { + return state.sources.filter(source => source.scrapingType === scrapingType); + }, + + htmlSources: (state) => { + return state.sources.filter(source => source.scrapingType === 'HTML'); + }, + + javascriptSources: (state) => { + return state.sources.filter(source => source.scrapingType === 'Javascript'); + } + }, + + actions: { + // Load all sources + async loadSources() { + if (this.loadingSources) return; + + this.loadingSources = true; + this.sourcesError = null; + + try { + this.sources = await contentSourceRepository.getAll(); + } catch (error) { + this.sourcesError = error.message; + console.error('Erreur lors du chargement des sources:', error); + } finally { + this.loadingSources = false; + } + }, + + // Load specific source by ID + async loadSource(id) { + if (this.loadingCurrentSource) return; + + this.loadingCurrentSource = true; + this.currentSourceError = null; + + try { + this.currentSource = await contentSourceRepository.getById(id); + } catch (error) { + this.currentSourceError = error.message; + console.error('Erreur lors du chargement de la source:', error); + } finally { + this.loadingCurrentSource = false; + } + }, + + // Create new source + async createSource(sourceData) { + if (this.saving) return; + + this.saving = true; + this.saveError = null; + + try { + const newSource = await contentSourceRepository.create(sourceData); + this.sources.push(newSource); + return newSource; + } catch (error) { + this.saveError = error.message; + console.error('Erreur lors de la création de la source:', error); + throw error; + } finally { + this.saving = false; + } + }, + + // Update existing source + async updateSource(id, sourceData) { + if (this.saving) return; + + this.saving = true; + this.saveError = null; + + try { + const updatedSource = await contentSourceRepository.update(id, sourceData); + + // Update in sources array + const index = this.sources.findIndex(source => source.id === id); + if (index !== -1) { + this.sources[index] = updatedSource; + } + + // Update current source if it's the same + if (this.currentSource && this.currentSource.id === id) { + this.currentSource = updatedSource; + } + + return updatedSource; + } catch (error) { + this.saveError = error.message; + console.error('Erreur lors de la mise à jour de la source:', error); + throw error; + } finally { + this.saving = false; + } + }, + + // Export sources + async exportSources() { + if (this.exporting) return; + + this.exporting = true; + this.exportError = null; + + try { + return await contentSourceRepository.export(); + } catch (error) { + this.exportError = error.message; + console.error('Erreur lors de l\'export:', error); + throw error; + } finally { + this.exporting = false; + } + }, + + // Import sources + async importSources(sourcesData) { + if (this.importing) return; + + this.importing = true; + this.importError = null; + + try { + await contentSourceRepository.import(sourcesData); + // Reload sources after import + await this.loadSources(); + } catch (error) { + this.importError = error.message; + console.error('Erreur lors de l\'import:', error); + throw error; + } finally { + this.importing = false; + } + }, + + // Clear current source + clearCurrentSource() { + this.currentSource = null; + this.currentSourceError = null; + }, + + // Clear errors + clearErrors() { + this.sourcesError = null; + this.currentSourceError = null; + this.saveError = null; + this.importError = null; + this.exportError = null; + } + } +}); diff --git a/assets/vue/app/domain/setting/domain/constants/ScrapingTypes.js b/assets/vue/app/domain/setting/domain/constants/ScrapingTypes.js new file mode 100644 index 0000000..1915bb3 --- /dev/null +++ b/assets/vue/app/domain/setting/domain/constants/ScrapingTypes.js @@ -0,0 +1,55 @@ +/** + * Types de scraping disponibles + */ +export const SCRAPING_TYPES = { + HTML: 'html', + JAVASCRIPT: 'javascript' +}; + +/** + * Orientations de lecture + */ +export const READING_ORIENTATIONS = { + VERTICAL: 'vertical', + HORIZONTAL: 'horizontal' +}; + +/** + * États des sources + */ +export const SOURCE_STATUS = { + ACTIVE: 'active', + INACTIVE: 'inactive', + ERROR: 'error' +}; + +/** + * Configuration par défaut pour une nouvelle source + */ +export const DEFAULT_SOURCE_CONFIG = { + baseUrl: '', + chapterUrlFormat: '', + scrapingType: SCRAPING_TYPES.HTML, + imageSelector: '', + nextPageSelector: '', + chapterSelector: '', + token: '' +}; + +/** + * Validateurs pour les champs + */ +export const VALIDATORS = { + URL_PATTERN: /^https?:\/\/.+/, + SELECTOR_PATTERN: /^[.#]?[\w\-\s.#\[\]=\"':()>+~,]+$/ +}; + +/** + * Messages d'erreur + */ +export const ERROR_MESSAGES = { + INVALID_URL: 'L\'URL doit commencer par http:// ou https://', + INVALID_SELECTOR: 'Le sélecteur CSS n\'est pas valide', + REQUIRED_FIELD: 'Ce champ est obligatoire', + CHAPTER_URL_FORMAT_REQUIRED: 'Le format d\'URL des chapitres est obligatoire' +}; diff --git a/assets/vue/app/domain/setting/domain/models/ContentSource.js b/assets/vue/app/domain/setting/domain/models/ContentSource.js new file mode 100644 index 0000000..3ef2b5f --- /dev/null +++ b/assets/vue/app/domain/setting/domain/models/ContentSource.js @@ -0,0 +1,116 @@ +/** + * Modèle représentant une source de contenu pour le scraping + */ +export class ContentSource { + constructor({ + id = null, + baseUrl = '', + chapterUrlFormat = '', + scrapingType = 'HTML', + imageSelector = null, + nextPageSelector = null, + chapterSelector = null, + cleanBaseUrl = '', + token = null + } = {}) { + this.id = id; + this.baseUrl = baseUrl; + this.chapterUrlFormat = chapterUrlFormat; + this.scrapingType = scrapingType; + this.imageSelector = imageSelector; + this.nextPageSelector = nextPageSelector; + this.chapterSelector = chapterSelector; + this.cleanBaseUrl = cleanBaseUrl || this.extractCleanBaseUrl(baseUrl); + this.token = token; + } + + /** + * Extrait une URL propre à partir de l'URL de base + */ + extractCleanBaseUrl(url) { + if (!url) return ''; + + try { + const urlObj = new URL(url); + return urlObj.hostname; + } catch (error) { + return url; + } + } + + /** + * Vérifie si la source est valide + */ + isValid() { + return !!(this.baseUrl && this.chapterUrlFormat); + } + + /** + * Vérifie si la source est de type JavaScript + */ + isJavascriptSource() { + return this.scrapingType === 'Javascript'; + } + + /** + * Vérifie si la source est de type HTML + */ + isHtmlSource() { + return this.scrapingType === 'HTML'; + } + + /** + * Détermine l'orientation basée sur les sélecteurs + */ + getOrientation() { + return this.nextPageSelector ? 'vertical' : 'horizontal'; + } + + /** + * Génère une URL de chapitre basée sur le format + */ + generateChapterUrl(slug, chapterNumber) { + return this.chapterUrlFormat + .replace('{slug}', slug) + .replace('{chapterNumber}', chapterNumber); + } + + /** + * Convertit l'objet en format API + */ + toApiFormat() { + return { + baseUrl: this.baseUrl, + chapterUrlFormat: this.chapterUrlFormat, + scrapingType: this.scrapingType, + imageSelector: this.imageSelector, + nextPageSelector: this.nextPageSelector, + chapterSelector: this.chapterSelector, + token: this.token + }; + } + + /** + * Crée une instance depuis les données API + */ + static fromApiData(data) { + return new ContentSource(data); + } + + /** + * Clone l'instance + */ + clone() { + return new ContentSource({ + id: this.id, + baseUrl: this.baseUrl, + chapterUrlFormat: this.chapterUrlFormat, + scrapingType: this.scrapingType, + imageSelector: this.imageSelector, + nextPageSelector: this.nextPageSelector, + chapterSelector: this.chapterSelector, + cleanBaseUrl: this.cleanBaseUrl, + token: this.token + }); + } +} diff --git a/assets/vue/app/domain/setting/infrastructure/api/apiContentSourceRepository.js b/assets/vue/app/domain/setting/infrastructure/api/apiContentSourceRepository.js new file mode 100644 index 0000000..e5a395a --- /dev/null +++ b/assets/vue/app/domain/setting/infrastructure/api/apiContentSourceRepository.js @@ -0,0 +1,86 @@ +import axios from 'axios'; + +export class ApiContentSourceRepository { + constructor() { + this.apiClient = axios.create({ + baseURL: '/api', + headers: { + 'Content-Type': 'application/json', + }, + }); + } + + /** + * Récupère toutes les sources de contenu + */ + async getAll() { + try { + const response = await this.apiClient.get('/content-sources'); + return response.data; + } catch (error) { + throw new Error(error.response?.data?.message || 'Erreur lors de la récupération des sources'); + } + } + + /** + * Récupère une source de contenu par son ID + */ + async getById(id) { + try { + const response = await this.apiClient.get(`/content-sources/${id}`); + return response.data; + } catch (error) { + throw new Error(error.response?.data?.message || 'Erreur lors de la récupération de la source'); + } + } + + /** + * Crée une nouvelle source de contenu + */ + async create(contentSource) { + try { + const response = await this.apiClient.post('/content-sources', contentSource); + return response.data; + } catch (error) { + throw new Error(error.response?.data?.message || 'Erreur lors de la création de la source'); + } + } + + /** + * Met à jour une source de contenu + */ + async update(id, contentSource) { + try { + const response = await this.apiClient.put(`/content-sources/${id}`, contentSource); + return response.data; + } catch (error) { + throw new Error(error.response?.data?.message || 'Erreur lors de la mise à jour de la source'); + } + } + + /** + * Exporte toutes les sources de contenu + */ + async export() { + try { + const response = await this.apiClient.get('/content-sources/export'); + return response.data; + } catch (error) { + throw new Error(error.response?.data?.message || 'Erreur lors de l\'export des sources'); + } + } + + /** + * Importe des sources de contenu + */ + async import(contentSources) { + try { + const response = await this.apiClient.post('/content-sources/import', { + contentSources + }); + return response.data; + } catch (error) { + throw new Error(error.response?.data?.message || 'Erreur lors de l\'import des sources'); + } + } +} diff --git a/assets/vue/app/domain/setting/presentation/components/ContentSourceCard.vue b/assets/vue/app/domain/setting/presentation/components/ContentSourceCard.vue new file mode 100644 index 0000000..e3ebc89 --- /dev/null +++ b/assets/vue/app/domain/setting/presentation/components/ContentSourceCard.vue @@ -0,0 +1,89 @@ + + + diff --git a/assets/vue/app/domain/setting/presentation/components/ContentSourceForm.vue b/assets/vue/app/domain/setting/presentation/components/ContentSourceForm.vue new file mode 100644 index 0000000..21e4b3f --- /dev/null +++ b/assets/vue/app/domain/setting/presentation/components/ContentSourceForm.vue @@ -0,0 +1,268 @@ + + + diff --git a/assets/vue/app/domain/setting/presentation/composables/useContentSources.js b/assets/vue/app/domain/setting/presentation/composables/useContentSources.js new file mode 100644 index 0000000..36dd09f --- /dev/null +++ b/assets/vue/app/domain/setting/presentation/composables/useContentSources.js @@ -0,0 +1,56 @@ +import { computed } from 'vue'; +import { useContentSourceStore } from '../../application/store/contentSourceStore'; + +export function useContentSources() { + const store = useContentSourceStore(); + + // Computed properties pour un accès facile aux données + const sources = computed(() => store.sources); + const currentSource = computed(() => store.currentSource); + const isLoading = computed(() => store.loadingSources || store.loadingCurrentSource); + const isSaving = computed(() => store.saving); + const hasError = computed(() => store.sourcesError || store.currentSourceError || store.saveError); + + // Getters + const getSourceById = (id) => store.getSourceById(id); + const getSourcesByType = (type) => store.getSourcesByType(type); + const htmlSources = computed(() => store.htmlSources); + const javascriptSources = computed(() => store.javascriptSources); + + // Actions + const loadSources = () => store.loadSources(); + const loadSource = (id) => store.loadSource(id); + const createSource = (data) => store.createSource(data); + const updateSource = (id, data) => store.updateSource(id, data); + const exportSources = () => store.exportSources(); + const importSources = (data) => store.importSources(data); + const clearCurrentSource = () => store.clearCurrentSource(); + const clearErrors = () => store.clearErrors(); + + return { + // Data + sources, + currentSource, + + // States + isLoading, + isSaving, + hasError, + + // Getters + getSourceById, + getSourcesByType, + htmlSources, + javascriptSources, + + // Actions + loadSources, + loadSource, + createSource, + updateSource, + exportSources, + importSources, + clearCurrentSource, + clearErrors + }; +} diff --git a/assets/vue/app/domain/setting/presentation/pages/ScrapperConfigurations.vue b/assets/vue/app/domain/setting/presentation/pages/ScrapperConfigurations.vue new file mode 100644 index 0000000..c576024 --- /dev/null +++ b/assets/vue/app/domain/setting/presentation/pages/ScrapperConfigurations.vue @@ -0,0 +1,230 @@ + + + diff --git a/assets/vue/app/domain/setting/presentation/pages/ScrapperEdit.vue b/assets/vue/app/domain/setting/presentation/pages/ScrapperEdit.vue new file mode 100644 index 0000000..aa58af3 --- /dev/null +++ b/assets/vue/app/domain/setting/presentation/pages/ScrapperEdit.vue @@ -0,0 +1,228 @@ + + + diff --git a/assets/vue/app/router/index.js b/assets/vue/app/router/index.js index b491088..f634a82 100644 --- a/assets/vue/app/router/index.js +++ b/assets/vue/app/router/index.js @@ -1,10 +1,12 @@ import { createRouter, createWebHistory } from 'vue-router'; -import Layout from '../shared/components/layout/Layout.vue'; +import ActivityPage from '../domain/activity/presentation/pages/ActivityPage.vue'; +import AddManga from '../domain/manga/presentation/pages/AddManga.vue'; import HomePage from '../domain/manga/presentation/pages/HomePage.vue'; import MangaDetails from '../domain/manga/presentation/pages/MangaDetails.vue'; import ChapterPage from '../domain/reader/presentation/pages/ChapterPage.vue'; -import ActivityPage from '../domain/activity/presentation/pages/ActivityPage.vue'; -import AddManga from '../domain/manga/presentation/pages/AddManga.vue'; +import ScrapperConfigurations from '../domain/setting/presentation/pages/ScrapperConfigurations.vue'; +import ScrapperEdit from '../domain/setting/presentation/pages/ScrapperEdit.vue'; +import Layout from '../shared/components/layout/Layout.vue'; // Placeholder component for new routes const PlaceholderComponent = { @@ -104,9 +106,18 @@ const routes = [ }, { path: '/settings/scrappers', - name: 'settings-scrappers', - component: PlaceholderComponent, - props: { title: 'Configuration des scrappers' } + name: 'scrapper-configurations', + component: ScrapperConfigurations + }, + { + path: '/settings/scrappers/new', + name: 'scrapper-new', + component: ScrapperEdit + }, + { + path: '/settings/scrappers/edit/:id', + name: 'scrapper-edit', + component: ScrapperEdit }, { path: '/settings/ui', diff --git a/src/Domain/Setting/Infrastructure/ApiPlatform/Resource/ExportContentSourceResource.php b/src/Domain/Setting/Infrastructure/ApiPlatform/Resource/ExportContentSourceResource.php index bcea491..745f952 100644 --- a/src/Domain/Setting/Infrastructure/ApiPlatform/Resource/ExportContentSourceResource.php +++ b/src/Domain/Setting/Infrastructure/ApiPlatform/Resource/ExportContentSourceResource.php @@ -7,7 +7,7 @@ use ApiPlatform\Metadata\Get; use App\Domain\Setting\Infrastructure\ApiPlatform\State\Provider\ExportContentSourceStateProvider; #[ApiResource( - shortName: 'ContentSourceExport', + shortName: 'ContentSource', operations: [ new Get( uriTemplate: '/content-sources/export', diff --git a/src/Domain/Setting/Infrastructure/ApiPlatform/Resource/ImportContentSourceResource.php b/src/Domain/Setting/Infrastructure/ApiPlatform/Resource/ImportContentSourceResource.php index c3d3fa5..8ea86f7 100644 --- a/src/Domain/Setting/Infrastructure/ApiPlatform/Resource/ImportContentSourceResource.php +++ b/src/Domain/Setting/Infrastructure/ApiPlatform/Resource/ImportContentSourceResource.php @@ -8,7 +8,7 @@ use App\Domain\Setting\Infrastructure\ApiPlatform\State\Processor\ImportContentS use Symfony\Component\Validator\Constraints as Assert; #[ApiResource( - shortName: 'ContentSourceImport', + shortName: 'ContentSource', operations: [ new Post( uriTemplate: '/content-sources/import', diff --git a/src/Domain/Setting/Infrastructure/ApiPlatform/Resource/UpsertContentSourceResource.php b/src/Domain/Setting/Infrastructure/ApiPlatform/Resource/UpsertContentSourceResource.php index 7137227..cd5b9f0 100644 --- a/src/Domain/Setting/Infrastructure/ApiPlatform/Resource/UpsertContentSourceResource.php +++ b/src/Domain/Setting/Infrastructure/ApiPlatform/Resource/UpsertContentSourceResource.php @@ -6,6 +6,7 @@ use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Put; use App\Domain\Setting\Infrastructure\ApiPlatform\State\Processor\UpsertContentSourceStateProcessor; +use App\Domain\Setting\Infrastructure\ApiPlatform\State\Provider\GetContentSourceStateProvider; use Symfony\Component\Validator\Constraints as Assert; #[ApiResource( @@ -20,6 +21,7 @@ use Symfony\Component\Validator\Constraints as Assert; ), new Put( uriTemplate: '/content-sources/{id}', + provider: GetContentSourceStateProvider::class, processor: UpsertContentSourceStateProcessor::class, input: UpsertContentSourceResource::class, description: 'Met à jour une source de contenu existante' diff --git a/src/Domain/Setting/Infrastructure/Persistence/Mapper/ContentSourceMapper.php b/src/Domain/Setting/Infrastructure/Persistence/Mapper/ContentSourceMapper.php index dc83996..93d59ca 100644 --- a/src/Domain/Setting/Infrastructure/Persistence/Mapper/ContentSourceMapper.php +++ b/src/Domain/Setting/Infrastructure/Persistence/Mapper/ContentSourceMapper.php @@ -33,4 +33,16 @@ readonly class ContentSourceMapper return $entity; } + + public function updateEntity(ContentSourceEntity $entity, ContentSource $contentSource): ContentSourceEntity + { + $entity->setBaseUrl($contentSource->getBaseUrl()) + ->setChapterUrlFormat($contentSource->getChapterUrlFormat()) + ->setScrapingType($contentSource->getScrapingType()) + ->setImageSelector($contentSource->getImageSelector()) + ->setNextPageSelector($contentSource->getNextPageSelector()) + ->setChapterSelector($contentSource->getChapterSelector()); + + return $entity; + } } diff --git a/src/Domain/Setting/Infrastructure/Persistence/Repository/DoctrineContentSourceRepository.php b/src/Domain/Setting/Infrastructure/Persistence/Repository/DoctrineContentSourceRepository.php index 686efc1..e17230e 100644 --- a/src/Domain/Setting/Infrastructure/Persistence/Repository/DoctrineContentSourceRepository.php +++ b/src/Domain/Setting/Infrastructure/Persistence/Repository/DoctrineContentSourceRepository.php @@ -34,14 +34,24 @@ readonly class DoctrineContentSourceRepository implements ContentSourceRepositor public function save(ContentSource $contentSource): void { - $entity = $this->mapper->toEntity($contentSource); + if ($contentSource->getId()) { + // Update existing entity + $entity = $this->entityManager->find(ContentSourceEntity::class, $contentSource->getId()); + if ($entity) { + // Update existing entity with new values + $this->mapper->updateEntity($entity, $contentSource); + $this->entityManager->flush(); + } + } else { + // Create new entity + $entity = $this->mapper->toEntity($contentSource); + $this->entityManager->persist($entity); + $this->entityManager->flush(); - $this->entityManager->persist($entity); - $this->entityManager->flush(); - - // Met à jour l'ID du modèle du domaine si nécessaire - if ($entity->getId() && $contentSource->getId() === null) { - $contentSource->updateId($entity->getId()); + // Met à jour l'ID du modèle du domaine + if ($entity->getId()) { + $contentSource->updateId($entity->getId()); + } } }