Compare commits
31 Commits
fix/reader
...
feat/monit
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2289156f57 | ||
|
|
f42b5a9cf5 | ||
| 214f470e77 | |||
|
|
345434c25d | ||
| 2868772f5c | |||
| a2469b6c07 | |||
|
|
926f938c45 | ||
| 5551d73962 | |||
| 395a0a16cb | |||
|
|
8e2e608ad9 | ||
| 0f80cb9fec | |||
| a3477629fb | |||
|
|
cde701986e | ||
| b921768aef | |||
|
|
5f0178f784 | ||
| c610d22bd2 | |||
| ab2cf319ac | |||
|
|
69c6757cf8 | ||
|
|
21d8111734 | ||
|
|
5ed303612a | ||
| 4e30af6a16 | |||
|
|
5a0888eb28 | ||
| d7e6bf56d0 | |||
| 17d44f68e5 | |||
|
|
90d6feee2d | ||
| 0880a77546 | |||
|
|
9926da6730 | ||
| 4c80aa6b42 | |||
| c0307a9173 | |||
|
|
45f7e88024 | ||
| 507fac5b5e |
@@ -1,7 +1,7 @@
|
|||||||
#syntax=docker/dockerfile:1.4
|
#syntax=docker/dockerfile:1.4
|
||||||
|
|
||||||
# Versions
|
# Versions
|
||||||
FROM dunglas/frankenphp:1-php8.3 AS frankenphp_upstream
|
FROM dunglas/frankenphp:1-php8.4 AS frankenphp_upstream
|
||||||
|
|
||||||
# The different stages of this Dockerfile are meant to be built into separate images
|
# The different stages of this Dockerfile are meant to be built into separate images
|
||||||
# https://docs.docker.com/develop/develop-images/multistage-build/#stop-at-a-specific-build-stage
|
# https://docs.docker.com/develop/develop-images/multistage-build/#stop-at-a-specific-build-stage
|
||||||
@@ -108,9 +108,6 @@ RUN composer install --no-cache --prefer-dist --no-dev --no-autoloader --no-scri
|
|||||||
FROM node:22-alpine AS node_build
|
FROM node:22-alpine AS node_build
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --link package.json package-lock.json ./
|
COPY --link package.json package-lock.json ./
|
||||||
COPY --from=composer_deps /app/vendor/symfony/ux-live-component/assets ./vendor/symfony/ux-live-component/assets
|
|
||||||
COPY --from=composer_deps /app/vendor/symfony/ux-react/assets ./vendor/symfony/ux-react/assets
|
|
||||||
COPY --from=composer_deps /app/vendor/symfony/ux-turbo/assets ./vendor/symfony/ux-turbo/assets
|
|
||||||
RUN npm install
|
RUN npm install
|
||||||
COPY --link assets ./assets
|
COPY --link assets ./assets
|
||||||
COPY --link webpack.config.js ./
|
COPY --link webpack.config.js ./
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
import './bootstrap.js';
|
|
||||||
|
|
||||||
import '@fortawesome/fontawesome-free/js/all.js';
|
|
||||||
/*
|
|
||||||
* Welcome to your app's main JavaScript file!
|
|
||||||
*
|
|
||||||
* We recommend including the built version of this JavaScript file
|
|
||||||
* (and its CSS file) in your base layout (base.html.twig).
|
|
||||||
*/
|
|
||||||
|
|
||||||
// any CSS you import will output into a single css file (app.css in this case)
|
|
||||||
import './styles/app.scss';
|
|
||||||
|
|
||||||
// start the Stimulus application
|
|
||||||
import './bootstrap';
|
|
||||||
|
|
||||||
// La ligne registerReactControllerComponents a déjà été commentée
|
|
||||||
35
assets/bootstrap.js
vendored
35
assets/bootstrap.js
vendored
@@ -1,35 +0,0 @@
|
|||||||
import { startStimulusApp } from '@symfony/stimulus-bridge';
|
|
||||||
|
|
||||||
// Registers Stimulus controllers from controllers.json and in the controllers/ directory
|
|
||||||
export const app = startStimulusApp(require.context(
|
|
||||||
'@symfony/stimulus-bridge/lazy-controller-loader!./controllers',
|
|
||||||
true,
|
|
||||||
/\.[jt]sx?$/
|
|
||||||
));
|
|
||||||
|
|
||||||
// register any custom, 3rd party controllers here
|
|
||||||
// app.register('some_controller_name', SomeImportedController);
|
|
||||||
|
|
||||||
//DEBUG TURBO
|
|
||||||
// import * as Turbo from "@hotwired/turbo"
|
|
||||||
//
|
|
||||||
// Turbo.session.drive = false
|
|
||||||
// Turbo.start()
|
|
||||||
//
|
|
||||||
// // Écouteurs existants
|
|
||||||
// document.addEventListener("turbo:before-stream-render", (event) => {
|
|
||||||
// console.log("Before stream render", event.target);
|
|
||||||
// });
|
|
||||||
//
|
|
||||||
// document.addEventListener("turbo:stream-render", (event) => {
|
|
||||||
// console.log("Stream rendered", event.target);
|
|
||||||
// });
|
|
||||||
//
|
|
||||||
// // Nouvel écouteur pour les événements de création
|
|
||||||
// document.addEventListener("turbo:before-fetch-request", (event) => {
|
|
||||||
// console.log("Before fetch request", event.detail.fetchOptions);
|
|
||||||
// });
|
|
||||||
//
|
|
||||||
// document.addEventListener("turbo:before-fetch-response", (event) => {
|
|
||||||
// console.log("Before fetch response", event.detail.fetchResponse);
|
|
||||||
// });
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
{
|
|
||||||
"controllers": {
|
|
||||||
"@symfony/ux-live-component": {
|
|
||||||
"live": {
|
|
||||||
"enabled": true,
|
|
||||||
"fetch": "eager",
|
|
||||||
"autoimport": {
|
|
||||||
"@symfony/ux-live-component/dist/live.min.css": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"@symfony/ux-react": {
|
|
||||||
"react": {
|
|
||||||
"enabled": true,
|
|
||||||
"fetch": "eager"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"@symfony/ux-turbo": {
|
|
||||||
"turbo-core": {
|
|
||||||
"enabled": true,
|
|
||||||
"fetch": "eager"
|
|
||||||
},
|
|
||||||
"mercure-turbo-stream": {
|
|
||||||
"enabled": true,
|
|
||||||
"fetch": "eager"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"entrypoints": []
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
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 = ['activity']
|
|
||||||
|
|
||||||
// ...
|
|
||||||
async connect() {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/activity/status`, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-Requested-With': 'XMLHttpRequest'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
// Handle the response data as needed
|
|
||||||
this.activityTarget.innerHTML = data.length;
|
|
||||||
if (data.length > 0) {
|
|
||||||
this.activityTarget.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error:', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const mercureHubUrl = 'https://mangarr.test.nestor-server.fr/.well-known/mercure';
|
|
||||||
const eventSource = new EventSource(`${mercureHubUrl}?topic=activity`, {withCredentials: true});
|
|
||||||
|
|
||||||
eventSource.onmessage = (event) => {
|
|
||||||
const data = JSON.parse(event.data);
|
|
||||||
if (data.processing !== undefined && data.pending !== undefined) {
|
|
||||||
let totalActivities = data.processing.length + data.pending.length;
|
|
||||||
this.activityTarget.innerHTML = totalActivities;
|
|
||||||
if (totalActivities > 0) {
|
|
||||||
this.activityTarget.classList.remove('hidden');
|
|
||||||
}else if (totalActivities === 0) {
|
|
||||||
this.activityTarget.classList.add('hidden');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
eventSource
|
|
||||||
.onerror = (event) => {
|
|
||||||
console.error('EventSource failed:', event);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
// assets/controllers/addmanga_controller.js
|
|
||||||
import { Controller } from "@hotwired/stimulus"
|
|
||||||
|
|
||||||
export default class extends Controller {
|
|
||||||
static values = {
|
|
||||||
index: Number
|
|
||||||
}
|
|
||||||
|
|
||||||
openModal(event) {
|
|
||||||
event.preventDefault()
|
|
||||||
const openEvent = new CustomEvent(`openAddMangaModal${this.indexValue}`)
|
|
||||||
document.dispatchEvent(openEvent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
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 = ['alert', 'icon', 'message']
|
|
||||||
|
|
||||||
connect() {
|
|
||||||
window.addEventListener('alert:show', this.showAlert.bind(this));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ...
|
|
||||||
showAlert(event) {
|
|
||||||
const detail = event.detail;
|
|
||||||
const message = detail.message;
|
|
||||||
const level = detail.level;
|
|
||||||
|
|
||||||
let alertClass = "";
|
|
||||||
let iconClass = "";
|
|
||||||
switch (level) {
|
|
||||||
case 'success':
|
|
||||||
alertClass = "bg-green-500";
|
|
||||||
iconClass = "fa-circle-check";
|
|
||||||
break;
|
|
||||||
case 'warning':
|
|
||||||
alertClass = "bg-yellow-500";
|
|
||||||
iconClass = "fa-circle-exclamation";
|
|
||||||
break;
|
|
||||||
case 'error':
|
|
||||||
alertClass = "bg-red-500";
|
|
||||||
iconClass = "fa-circle-xmark";
|
|
||||||
break;
|
|
||||||
case 'info':
|
|
||||||
default:
|
|
||||||
alertClass = "bg-blue-500";
|
|
||||||
iconClass = "fa-circle-info";
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.messageTarget.innerHTML = message;
|
|
||||||
this.alertTarget.classList.add(alertClass);
|
|
||||||
this.iconTarget.classList.add(iconClass);
|
|
||||||
this.alertTarget.style.display = "block";
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
this.alertTarget.style.opacity = 0;
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
this.alertTarget.style.display = 'none';
|
|
||||||
this.alertTarget.classList.remove(alertClass);
|
|
||||||
this.alertTarget.style.opacity = 1;
|
|
||||||
this.iconTarget.classList.remove(iconClass);
|
|
||||||
this.messageTarget.innerHTML = message;
|
|
||||||
}, 1000);
|
|
||||||
}, 3000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import { Controller } from '@hotwired/stimulus';
|
|
||||||
|
|
||||||
/* stimulusFetch: 'lazy' */
|
|
||||||
export default class extends Controller {
|
|
||||||
static targets = ['progressBar', 'progressText']
|
|
||||||
static values = {
|
|
||||||
chapterId: Number
|
|
||||||
}
|
|
||||||
|
|
||||||
connect() {
|
|
||||||
this.currentPage = 0;
|
|
||||||
this.totalPages = 0;
|
|
||||||
|
|
||||||
const mercureHubUrl = 'https://mangarr.test.nestor-server.fr/.well-known/mercure';
|
|
||||||
this.eventSource = new EventSource(`${mercureHubUrl}?topic=activity`, {withCredentials: true});
|
|
||||||
|
|
||||||
this.eventSource.onmessage = this.handleMessage.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
disconnect() {
|
|
||||||
if (this.eventSource) {
|
|
||||||
this.eventSource.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleMessage(event) {
|
|
||||||
const data = JSON.parse(event.data);
|
|
||||||
if (data.status === "scrapping.progress" && data.chapterId === this.chapterIdValue) {
|
|
||||||
this.handleProgressUpdate(data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleProgressUpdate(data) {
|
|
||||||
this.currentPage = data.pageIndex;
|
|
||||||
this.totalPages = data.totalPages;
|
|
||||||
|
|
||||||
this.updateProgressBar();
|
|
||||||
}
|
|
||||||
|
|
||||||
updateProgressBar() {
|
|
||||||
const progress = (this.currentPage / this.totalPages) * 100;
|
|
||||||
this.progressBarTarget.style.width = `${progress}%`;
|
|
||||||
this.progressTextTarget.textContent = `${this.currentPage} / ${this.totalPages}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
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 = ['container', 'template', 'item'];
|
|
||||||
|
|
||||||
connect() {
|
|
||||||
this.index = this.itemTargets.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
add(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
const template = this.templateTarget.innerHTML.replace(/__name__/g, this.index);
|
|
||||||
this.containerTarget.insertAdjacentHTML('beforeend', template);
|
|
||||||
this.index++;
|
|
||||||
}
|
|
||||||
|
|
||||||
remove(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
event.target.closest('.collection-item').remove();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
import { Controller } from '@hotwired/stimulus';
|
|
||||||
|
|
||||||
/* stimulusFetch: 'lazy' */
|
|
||||||
export default class extends Controller {
|
|
||||||
static targets = ['icon']
|
|
||||||
static values = {
|
|
||||||
url: String
|
|
||||||
}
|
|
||||||
|
|
||||||
connect() {
|
|
||||||
this.defaultIconClass = this.iconTarget.classList.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
async download(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
// Change the icon to a loader
|
|
||||||
this.iconTarget.classList.remove("fa-download", "fa-search");
|
|
||||||
this.iconTarget.classList.add("fa-spinner", "fa-spin");
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(this.urlValue, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
'Accept': 'application/json',
|
|
||||||
'X-Requested-With': 'XMLHttpRequest'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const contentType = response.headers.get("Content-Type");
|
|
||||||
if (contentType && contentType.includes("application/json")) {
|
|
||||||
const data = await response.json();
|
|
||||||
if (data.error) {
|
|
||||||
this.dispatchAlert(data.error, 'error');
|
|
||||||
} else if (data.success) {
|
|
||||||
this.dispatchAlert(data.success, 'success');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// C'est un fichier à télécharger
|
|
||||||
const blob = await response.blob();
|
|
||||||
const url = window.URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.style.display = 'none';
|
|
||||||
a.href = url;
|
|
||||||
const contentDisposition = response.headers.get('Content-Disposition');
|
|
||||||
const filenameRegex = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/;
|
|
||||||
const matches = filenameRegex.exec(contentDisposition);
|
|
||||||
let filename = 'download';
|
|
||||||
if (matches != null && matches[1]) {
|
|
||||||
filename = matches[1].replace(/['"]/g, '');
|
|
||||||
}
|
|
||||||
a.download = filename;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
window.URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
// Revert the icon back to the original one
|
|
||||||
this.iconTarget.classList.value = this.defaultIconClass;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatchAlert(message, level) {
|
|
||||||
const event = new CustomEvent('alert:show', {
|
|
||||||
detail: { message: message, level: level }
|
|
||||||
});
|
|
||||||
window.dispatchEvent(event);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
// assets/controllers/dropdown_controller.js
|
|
||||||
import {Controller} from "@hotwired/stimulus"
|
|
||||||
import {useClickOutside} from "stimulus-use"
|
|
||||||
|
|
||||||
export default class extends Controller {
|
|
||||||
static targets = ["button", "menu"]
|
|
||||||
|
|
||||||
connect() {
|
|
||||||
useClickOutside(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
toggle(event) {
|
|
||||||
this.menuTarget.classList.toggle('hidden')
|
|
||||||
if (!this.menuTarget.classList.contains('hidden')) {
|
|
||||||
this.positionMenu()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
clickOutside(event) {
|
|
||||||
this.menuTarget.classList.add('hidden')
|
|
||||||
}
|
|
||||||
|
|
||||||
positionMenu() {
|
|
||||||
const buttonRect = this.buttonTarget.getBoundingClientRect()
|
|
||||||
const menuRect = this.menuTarget.getBoundingClientRect()
|
|
||||||
const spaceRight = window.innerWidth - buttonRect.right
|
|
||||||
const spaceBottom = window.innerHeight - buttonRect.bottom
|
|
||||||
|
|
||||||
if (spaceRight < menuRect.width && buttonRect.left > menuRect.width) {
|
|
||||||
this.menuTarget.style.left = 'auto'
|
|
||||||
this.menuTarget.style.right = '0'
|
|
||||||
} else {
|
|
||||||
this.menuTarget.style.left = '0'
|
|
||||||
this.menuTarget.style.right = 'auto'
|
|
||||||
}
|
|
||||||
|
|
||||||
if (spaceBottom < menuRect.height && buttonRect.top > menuRect.height) {
|
|
||||||
this.menuTarget.style.top = 'auto'
|
|
||||||
this.menuTarget.style.bottom = '100%'
|
|
||||||
} else {
|
|
||||||
this.menuTarget.style.top = '100%'
|
|
||||||
this.menuTarget.style.bottom = 'auto'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { Controller } from '@hotwired/stimulus';
|
|
||||||
|
|
||||||
/*
|
|
||||||
* This is an example Stimulus controller!
|
|
||||||
*
|
|
||||||
* Any element with a data-controller="hello" attribute will cause
|
|
||||||
* this controller to be executed. The name "hello" comes from the filename:
|
|
||||||
* hello_controller.js -> "hello"
|
|
||||||
*
|
|
||||||
* Delete this file or adapt it for your use!
|
|
||||||
*/
|
|
||||||
export default class extends Controller {
|
|
||||||
connect() {
|
|
||||||
this.element.textContent = 'Hello Stimulus! Edit me in assets/controllers/hello_controller.js';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import { Controller } from '@hotwired/stimulus';
|
|
||||||
|
|
||||||
export default class extends Controller {
|
|
||||||
static targets = ["checkbox", "modal", "modalContent"]
|
|
||||||
|
|
||||||
toggleAllCheckboxes(event) {
|
|
||||||
this.checkboxTargets.forEach(checkbox => {
|
|
||||||
checkbox.checked = event.target.checked;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
updateMangaInfo(event) {
|
|
||||||
const select = event.target;
|
|
||||||
const selectedOption = select.options[select.selectedIndex];
|
|
||||||
const mangaInfo = JSON.parse(selectedOption.dataset.mangaInfo);
|
|
||||||
}
|
|
||||||
|
|
||||||
showDetails(event) {
|
|
||||||
const fileId = event.currentTarget.dataset.fileId;
|
|
||||||
const select = document.querySelector(`select[name="manga_slug[${fileId}]"]`);
|
|
||||||
const mangaInfo = JSON.parse(select.options[select.selectedIndex].dataset.mangaInfo);
|
|
||||||
|
|
||||||
this.modalContentTarget.innerHTML = `
|
|
||||||
<h3 class="text-lg leading-6 font-medium text-gray-900">${mangaInfo.title}</h3>
|
|
||||||
<div class="mt-2">
|
|
||||||
<p><strong>Author:</strong> ${mangaInfo.author || 'N/A'}</p>
|
|
||||||
<p><strong>Publication Year:</strong> ${mangaInfo.publicationYear || 'N/A'}</p>
|
|
||||||
<p><strong>Genres:</strong> ${mangaInfo.genres ? mangaInfo.genres.join(', ') : 'N/A'}</p>
|
|
||||||
<p><strong>Description:</strong> ${this.truncate(mangaInfo.description || 'N/A', 200)}</p>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
this.modalTarget.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
closeModal() {
|
|
||||||
this.modalTarget.classList.add('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
confirmSelected(event) {
|
|
||||||
const selectedFiles = this.checkboxTargets.filter(checkbox => checkbox.checked).map(checkbox => checkbox.value);
|
|
||||||
if (selectedFiles.length === 0) {
|
|
||||||
event.preventDefault();
|
|
||||||
alert('Veuillez sélectionner au moins un fichier à importer.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
truncate(str, length) {
|
|
||||||
return str.length > length ? str.substring(0, length) + '...' : str;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
// assets/controllers/loading_button_controller.js
|
|
||||||
import {Controller} from "@hotwired/stimulus"
|
|
||||||
|
|
||||||
export default class extends Controller {
|
|
||||||
static targets = ["text", "loader"];
|
|
||||||
static values = {form: String};
|
|
||||||
|
|
||||||
startLoading(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
this.textTarget.classList.add("hidden");
|
|
||||||
this.loaderTarget.classList.remove("hidden");
|
|
||||||
this.element.disabled = true;
|
|
||||||
|
|
||||||
if (this.hasFormValue) {
|
|
||||||
const form = document.getElementById(this.formValue);
|
|
||||||
if (form) {
|
|
||||||
form.submit();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
// assets/controllers/menu_controller.js
|
|
||||||
import { Controller } from '@hotwired/stimulus';
|
|
||||||
|
|
||||||
export default class extends Controller {
|
|
||||||
static targets = ["sidebar"]
|
|
||||||
|
|
||||||
toggleMenu() {
|
|
||||||
this.sidebarTarget.classList.toggle('-translate-x-full')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
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 {
|
|
||||||
// ...
|
|
||||||
connect() {
|
|
||||||
const topic = this.data.get('topic');
|
|
||||||
const mercureHubUrl = 'https://mangarr.test.nestor-server.fr/.well-known/mercure';
|
|
||||||
const eventSource = new EventSource(`${mercureHubUrl}?topic=${topic}`, {withCredentials: true});
|
|
||||||
|
|
||||||
eventSource.onmessage = (event) => {
|
|
||||||
const data = JSON.parse(event.data);
|
|
||||||
console.log('Received Mercure update:', data);
|
|
||||||
|
|
||||||
this.dispatchAlert(data.message, data.status);
|
|
||||||
};
|
|
||||||
|
|
||||||
eventSource.onerror = (event) => {
|
|
||||||
console.error('EventSource failed:', event);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatchAlert(message, level) {
|
|
||||||
const event = new CustomEvent('alert:show', {
|
|
||||||
detail: { message: message, level: level }
|
|
||||||
});
|
|
||||||
window.dispatchEvent(event);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
// assets/controllers/modal_controller.js
|
|
||||||
import { Controller } from "@hotwired/stimulus"
|
|
||||||
|
|
||||||
export default class extends Controller {
|
|
||||||
static targets = ["modal"]
|
|
||||||
static values = {
|
|
||||||
openTrigger: String,
|
|
||||||
closeTrigger: String
|
|
||||||
}
|
|
||||||
|
|
||||||
connect() {
|
|
||||||
if (this.hasOpenTriggerValue) {
|
|
||||||
document.addEventListener(this.openTriggerValue, this.open.bind(this))
|
|
||||||
}
|
|
||||||
if (this.hasCloseTriggerValue) {
|
|
||||||
document.addEventListener(this.closeTriggerValue, this.close.bind(this))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
disconnect() {
|
|
||||||
if (this.hasOpenTriggerValue) {
|
|
||||||
document.removeEventListener(this.openTriggerValue, this.open.bind(this))
|
|
||||||
}
|
|
||||||
if (this.hasCloseTriggerValue) {
|
|
||||||
document.removeEventListener(this.closeTriggerValue, this.close.bind(this))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
open() {
|
|
||||||
console.log("Opening modal...")
|
|
||||||
this.modalTarget.classList.remove('hidden')
|
|
||||||
}
|
|
||||||
|
|
||||||
close() {
|
|
||||||
this.modalTarget.classList.add('hidden')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
// assets/controllers/preferred-sources_controller.js
|
|
||||||
|
|
||||||
import {Controller} from "@hotwired/stimulus"
|
|
||||||
import Sortable from 'sortablejs'
|
|
||||||
|
|
||||||
export default class extends Controller {
|
|
||||||
static targets = ["preferredList", "availableList"]
|
|
||||||
static values = {
|
|
||||||
mangaId: Number,
|
|
||||||
preferredSources: Array,
|
|
||||||
allSources: Array
|
|
||||||
}
|
|
||||||
|
|
||||||
connect() {
|
|
||||||
this.initSortable()
|
|
||||||
}
|
|
||||||
|
|
||||||
initSortable() {
|
|
||||||
new Sortable(this.preferredListTarget, {
|
|
||||||
animation: 150,
|
|
||||||
ghostClass: 'bg-gray-300',
|
|
||||||
onEnd: this.handleDragEnd.bind(this)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
handleDragEnd() {
|
|
||||||
this.updatePreferredSources()
|
|
||||||
}
|
|
||||||
|
|
||||||
addSource(event) {
|
|
||||||
const sourceId = parseInt(event.currentTarget.dataset.sourceId)
|
|
||||||
if (!this.preferredSourcesValue.includes(sourceId)) {
|
|
||||||
this.preferredSourcesValue = [...this.preferredSourcesValue, sourceId]
|
|
||||||
this.updateLists()
|
|
||||||
this.save()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
removeSource(event) {
|
|
||||||
const sourceId = parseInt(event.currentTarget.dataset.sourceId)
|
|
||||||
this.preferredSourcesValue = this.preferredSourcesValue.filter(id => id !== sourceId)
|
|
||||||
this.updateLists()
|
|
||||||
this.save()
|
|
||||||
}
|
|
||||||
|
|
||||||
updatePreferredSources() {
|
|
||||||
this.preferredSourcesValue = Array.from(this.preferredListTarget.children).map(li => parseInt(li.dataset.id))
|
|
||||||
this.save()
|
|
||||||
}
|
|
||||||
|
|
||||||
updateLists() {
|
|
||||||
this.preferredListTarget.innerHTML = this.preferredSourcesValue
|
|
||||||
.map(id => this.allSourcesValue.find(s => s.id === id))
|
|
||||||
.map(source => this.sourceTemplate(source, true))
|
|
||||||
.join('')
|
|
||||||
|
|
||||||
this.availableListTarget.innerHTML = this.allSourcesValue
|
|
||||||
.filter(source => !this.preferredSourcesValue.includes(source.id))
|
|
||||||
.map(source => this.sourceTemplate(source, false))
|
|
||||||
.join('')
|
|
||||||
|
|
||||||
this.initSortable()
|
|
||||||
}
|
|
||||||
|
|
||||||
sourceTemplate(source, isPreferred) {
|
|
||||||
return `
|
|
||||||
<li data-id="${source.id}" draggable="true" class="flex items-center justify-between p-2 bg-gray-100 rounded ${isPreferred ? 'cursor-move' : ''}">
|
|
||||||
<span>${source.name}</span>
|
|
||||||
<button type="button" data-action="preferred-sources#${isPreferred ? 'removeSource' : 'addSource'}" data-source-id="${source.id}" class="text-${isPreferred ? 'red' : 'green'}-500 hover:text-${isPreferred ? 'red' : 'green'}-700">
|
|
||||||
<i class="fas fa-${isPreferred ? 'times' : 'plus'}"></i>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
`
|
|
||||||
}
|
|
||||||
|
|
||||||
async save() {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/manga/${this.mangaIdValue}/preferred-sources`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-Requested-With': 'XMLHttpRequest'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
preferredSources: this.preferredSourcesValue
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
console.log('Preferred sources saved successfully')
|
|
||||||
// Optionally show a success message
|
|
||||||
} else {
|
|
||||||
console.error('Error saving preferred sources')
|
|
||||||
// Optionally show an error message
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error:', error)
|
|
||||||
// Optionally show an error message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
import { Controller } from '@hotwired/stimulus';
|
|
||||||
|
|
||||||
export default class extends Controller {
|
|
||||||
static targets = ['pageContainer', 'currentPage', 'chapterSelect', 'readingModeButton']
|
|
||||||
static values = {
|
|
||||||
mangaSlug: String,
|
|
||||||
chapterNumber: Number,
|
|
||||||
totalPages: Number,
|
|
||||||
currentPage: { type: Number, default: 1 },
|
|
||||||
readingMode: { type: String, default: 'horizontal' }
|
|
||||||
}
|
|
||||||
|
|
||||||
connect() {
|
|
||||||
this.loadChapters();
|
|
||||||
this.loadPages();
|
|
||||||
}
|
|
||||||
|
|
||||||
async loadChapters() {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/chapters/${this.mangaSlugValue}`);
|
|
||||||
const chapters = await response.json();
|
|
||||||
|
|
||||||
this.chapterSelectTarget.innerHTML = chapters.map(chapter =>
|
|
||||||
`<option value="${chapter.number}" ${chapter.number === this.chapterNumberValue ? 'selected' : ''}>
|
|
||||||
Chapitre ${chapter.number}
|
|
||||||
</option>`
|
|
||||||
).join('');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading chapters:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async loadPages() {
|
|
||||||
this.pageContainerTarget.innerHTML = '';
|
|
||||||
if (this.readingModeValue === 'horizontal') {
|
|
||||||
await this.loadPage(this.currentPageValue);
|
|
||||||
} else {
|
|
||||||
for (let i = 1; i <= this.totalPagesValue; i++) {
|
|
||||||
await this.loadPage(i, true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async loadPage(pageNumber, isVertical = false) {
|
|
||||||
const response = await fetch(`/api/read/${this.mangaSlugValue}/${this.chapterNumberValue}/${pageNumber}`);
|
|
||||||
const pageContent = await response.text();
|
|
||||||
|
|
||||||
const img = document.createElement('img');
|
|
||||||
img.src = `data:image/jpeg;base64,${pageContent}`;
|
|
||||||
img.alt = `Page ${pageNumber}`;
|
|
||||||
img.classList.add('shadow-lg', 'w-full', 'h-auto');
|
|
||||||
|
|
||||||
if (this.readingModeValue === 'horizontal') {
|
|
||||||
img.classList.add('cursor-pointer');
|
|
||||||
img.dataset.action = 'click->reader#pageClick';
|
|
||||||
this.pageContainerTarget.innerHTML = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isVertical) {
|
|
||||||
img.loading = 'lazy';
|
|
||||||
img.classList.add('mb-4');
|
|
||||||
}
|
|
||||||
|
|
||||||
this.pageContainerTarget.appendChild(img);
|
|
||||||
|
|
||||||
if (!isVertical) {
|
|
||||||
this.currentPageTarget.textContent = pageNumber;
|
|
||||||
this.currentPageValue = pageNumber;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pageClick(event) {
|
|
||||||
if (this.readingModeValue === 'horizontal') {
|
|
||||||
const pageWidth = event.target.offsetWidth;
|
|
||||||
const clickX = event.offsetX;
|
|
||||||
|
|
||||||
if (clickX < pageWidth / 2) {
|
|
||||||
this.previousPage();
|
|
||||||
} else {
|
|
||||||
this.nextPage();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
previousPage() {
|
|
||||||
if (this.currentPageValue > 1) {
|
|
||||||
this.loadPage(this.currentPageValue - 1);
|
|
||||||
} else {
|
|
||||||
this.previousChapter();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
nextPage() {
|
|
||||||
if (this.currentPageValue < this.totalPagesValue) {
|
|
||||||
this.loadPage(this.currentPageValue + 1);
|
|
||||||
} else {
|
|
||||||
this.nextChapter();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async previousChapter() {
|
|
||||||
const response = await fetch(`/api/previous-chapter/${this.mangaSlugValue}/${this.chapterNumberValue}`);
|
|
||||||
const previousChapter = await response.json();
|
|
||||||
if (previousChapter) {
|
|
||||||
window.location.href = `/read/${this.mangaSlugValue}/${previousChapter.number}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async nextChapter() {
|
|
||||||
const response = await fetch(`/api/next-chapter/${this.mangaSlugValue}/${this.chapterNumberValue}`);
|
|
||||||
const nextChapter = await response.json();
|
|
||||||
if (nextChapter) {
|
|
||||||
window.location.href = `/read/${this.mangaSlugValue}/${nextChapter.number}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
changeChapter(event) {
|
|
||||||
const selectedChapterNumber = event.target.value;
|
|
||||||
window.location.href = `/read/${this.mangaSlugValue}/${selectedChapterNumber}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleReadingMode() {
|
|
||||||
this.readingModeValue = this.readingModeValue === 'horizontal' ? 'vertical' : 'horizontal';
|
|
||||||
this.readingModeButtonTarget.textContent = this.readingModeValue === 'horizontal' ? 'Passer en mode vertical' : 'Passer en mode horizontal';
|
|
||||||
this.loadPages();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
import { Controller } from '@hotwired/stimulus';
|
|
||||||
|
|
||||||
export default class extends Controller {
|
|
||||||
static targets = ['form', 'testForm', 'imageSelector', 'nextPageSelector', 'testResults', 'scrapingType']
|
|
||||||
|
|
||||||
connect() {
|
|
||||||
}
|
|
||||||
|
|
||||||
async saveConfiguration(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
this.formTarget.submit();
|
|
||||||
}
|
|
||||||
|
|
||||||
async testConfiguration(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
const formData = new FormData(this.formTarget);
|
|
||||||
const testFormData = new FormData(this.testFormTarget);
|
|
||||||
|
|
||||||
for (let [key, value] of formData.entries()) {
|
|
||||||
const cleanKey = key.replace(/^content_source\[(.+)]$/, '$1');
|
|
||||||
testFormData.append(`content_source[${cleanKey}]`, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(this.testFormTarget.action, {
|
|
||||||
method: 'POST',
|
|
||||||
body: testFormData
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
this.displayTestResults(result.data);
|
|
||||||
} else {
|
|
||||||
this.displayError(result.message, result.errors);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.log(error)
|
|
||||||
this.displayError('An error occurred while testing the configuration');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
displayTestResults(data) {
|
|
||||||
let html = '<h3 class="text-xl font-semibold mb-4">Test Results</h3>';
|
|
||||||
html += '<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">';
|
|
||||||
data.forEach(page => {
|
|
||||||
html += `
|
|
||||||
<div class="border rounded-lg p-2 flex flex-col items-center">
|
|
||||||
<img src="${page.image_url}" alt="Page ${page.page_number}" class="w-full h-48 object-cover mb-2">
|
|
||||||
<p class="text-sm font-medium">Page ${page.page_number}</p>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
});
|
|
||||||
html += '</div>';
|
|
||||||
this.testResultsTarget.innerHTML = html;
|
|
||||||
}
|
|
||||||
|
|
||||||
displayError(message, errors = []) {
|
|
||||||
let errorHtml = `
|
|
||||||
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative" role="alert">
|
|
||||||
<strong class="font-bold">Error:</strong>
|
|
||||||
<span class="block sm:inline">${message}</span>
|
|
||||||
`;
|
|
||||||
|
|
||||||
if (errors.length > 0) {
|
|
||||||
errorHtml += '<ul class="list-disc list-inside mt-2">';
|
|
||||||
errors.forEach(error => {
|
|
||||||
errorHtml += `<li>${error}</li>`;
|
|
||||||
});
|
|
||||||
errorHtml += '</ul>';
|
|
||||||
}
|
|
||||||
|
|
||||||
errorHtml += '</div>';
|
|
||||||
this.testResultsTarget.innerHTML = errorHtml;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
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 = ['input']
|
|
||||||
|
|
||||||
clearSearch() {
|
|
||||||
this.inputTarget.value = '';
|
|
||||||
this.inputTarget.focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
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 = ["body", "toggleIcon"]
|
|
||||||
static values = { open: Boolean }
|
|
||||||
|
|
||||||
connect() {
|
|
||||||
if (!this.openValue) {
|
|
||||||
this.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
toggle() {
|
|
||||||
if (this.bodyTarget.style.display === "none") {
|
|
||||||
this.open()
|
|
||||||
} else {
|
|
||||||
this.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
open() {
|
|
||||||
this.bodyTarget.style.display = "block"
|
|
||||||
this.toggleIconTarget.classList.replace("fa-chevron-down", "fa-chevron-up")
|
|
||||||
}
|
|
||||||
|
|
||||||
close() {
|
|
||||||
this.bodyTarget.style.display = "none"
|
|
||||||
this.toggleIconTarget.classList.replace("fa-chevron-up", "fa-chevron-down")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,198 +0,0 @@
|
|||||||
// assets/controllers/toolbar_controller.js
|
|
||||||
import { Controller } from "@hotwired/stimulus"
|
|
||||||
import { visit } from "@hotwired/turbo"
|
|
||||||
|
|
||||||
export default class extends Controller {
|
|
||||||
static targets = ["dropdown", "icon", "text"]
|
|
||||||
static values = {
|
|
||||||
currentSort: String,
|
|
||||||
currentOrder: String,
|
|
||||||
currentStatus: String,
|
|
||||||
mangaId: Number
|
|
||||||
}
|
|
||||||
|
|
||||||
connect() {
|
|
||||||
window.addEventListener('alert:show', this.stopLoading.bind(this));
|
|
||||||
}
|
|
||||||
|
|
||||||
stopLoading(event) {
|
|
||||||
if(event.currentTarget.dataset !== undefined){
|
|
||||||
this.iconTarget.classList.remove('fa-spin');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
refreshMetadata(event) {
|
|
||||||
const mangaId = event.currentTarget.dataset.mangaid;
|
|
||||||
const url = `/refresh_metadata`;
|
|
||||||
|
|
||||||
this.iconTarget.classList.add('fa-spin');
|
|
||||||
|
|
||||||
fetch(url, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'X-Requested-With': 'XMLHttpRequest',
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ mangaId: mangaId })
|
|
||||||
})
|
|
||||||
.then(response => {
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Network response was not ok');
|
|
||||||
}
|
|
||||||
return response.json();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
searchLastChapter() {
|
|
||||||
console.log("Searching last chapter...");
|
|
||||||
}
|
|
||||||
|
|
||||||
import() {
|
|
||||||
console.log("Importing...");
|
|
||||||
}
|
|
||||||
|
|
||||||
monitoring(event){
|
|
||||||
const mangaId = event.currentTarget.dataset.mangaid;
|
|
||||||
const currentTarget = event.currentTarget;
|
|
||||||
|
|
||||||
const url = `/toggle_monitored`;
|
|
||||||
|
|
||||||
fetch(url, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'X-Requested-With': 'XMLHttpRequest',
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ mangaId: mangaId })
|
|
||||||
})
|
|
||||||
.then(response => {
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Network response was not ok');
|
|
||||||
}
|
|
||||||
return response.json();
|
|
||||||
}).then(data => {
|
|
||||||
if(data.isMonitored === true){
|
|
||||||
currentTarget.classList.remove('text-white');
|
|
||||||
currentTarget.classList.add('text-green-500');
|
|
||||||
this.textTarget.innerHTML = "Monitored";
|
|
||||||
}else if(data.isMonitored === false){
|
|
||||||
currentTarget.classList.remove('text-green-500');
|
|
||||||
currentTarget.classList.add('text-white');
|
|
||||||
this.textTarget.innerHTML = "Monitoring";
|
|
||||||
}
|
|
||||||
// console.log(data.isMonitored);
|
|
||||||
});
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
editMangas() {
|
|
||||||
console.log("Editing mangas...");
|
|
||||||
}
|
|
||||||
|
|
||||||
editManga() {
|
|
||||||
const event = new CustomEvent('openEditModal');
|
|
||||||
document.dispatchEvent(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
editPreferredSources() {
|
|
||||||
const event = new CustomEvent('openPreferredSourcesModal');
|
|
||||||
document.dispatchEvent(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
openImportModal() {
|
|
||||||
const importEvent = new CustomEvent('openImportModal');
|
|
||||||
document.dispatchEvent(importEvent);
|
|
||||||
}
|
|
||||||
|
|
||||||
openExportModal() {
|
|
||||||
const exportEvent = new CustomEvent('openExportModal');
|
|
||||||
document.dispatchEvent(exportEvent);
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteMangas() {
|
|
||||||
console.log("Deleting mangas...");
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteManga() {
|
|
||||||
const event = new CustomEvent('openDeleteModal');
|
|
||||||
document.dispatchEvent(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
confirmDelete(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
const url = `/manga/delete/${this.mangaIdValue}`;
|
|
||||||
|
|
||||||
fetch(url, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: {
|
|
||||||
'X-Requested-With': 'XMLHttpRequest',
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.then(response => {
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Network response was not ok');
|
|
||||||
}
|
|
||||||
return response.json();
|
|
||||||
})
|
|
||||||
.then(data => {
|
|
||||||
if (data.success) {
|
|
||||||
visit('/', {});
|
|
||||||
} else {
|
|
||||||
throw new Error(data.error);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
console.error('Error:', error);
|
|
||||||
// Show error message to user
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
showOptions() {
|
|
||||||
console.log("Showing options...");
|
|
||||||
}
|
|
||||||
|
|
||||||
expandAll() {
|
|
||||||
console.log("Expanding all...");
|
|
||||||
}
|
|
||||||
|
|
||||||
changeView(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
const viewOption = event.currentTarget.dataset.view;
|
|
||||||
|
|
||||||
const url = new URL(window.location);
|
|
||||||
url.searchParams.set('view', viewOption);
|
|
||||||
|
|
||||||
window.location = url.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
sort(event) {
|
|
||||||
event.preventDefault()
|
|
||||||
const sortOption = event.currentTarget.dataset.sort;
|
|
||||||
let order = 'asc';
|
|
||||||
|
|
||||||
if (sortOption === this.currentSortValue && this.currentOrderValue === 'asc') {
|
|
||||||
order = 'desc';
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = new URL(window.location);
|
|
||||||
url.searchParams.set('sort', sortOption);
|
|
||||||
url.searchParams.set('order', order);
|
|
||||||
|
|
||||||
window.location = url.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
filter(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
const filterOption = event.currentTarget.dataset.filter;
|
|
||||||
|
|
||||||
const url = new URL(window.location);
|
|
||||||
url.searchParams.set('status', filterOption);
|
|
||||||
|
|
||||||
// Réinitialiser la page à 1 si on utilise la pagination
|
|
||||||
// url.searchParams.set('page', '1');
|
|
||||||
|
|
||||||
window.location = url.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -14,14 +14,14 @@
|
|||||||
chapterId: chapter.id
|
chapterId: chapter.id
|
||||||
}
|
}
|
||||||
}">
|
}">
|
||||||
<template v-if="chapter.isVolumeGroup">
|
<template v-if="chapter.isVolumeGroup && chapter.volumeChapterCount > 1">
|
||||||
{{ chapter.volumeChapterCount > 1 ? 'Chapitres ' : 'Chapitre ' }}{{ chapter.volumeChaptersRange }}
|
Chapitres {{ chapter.volumeChaptersRange }}
|
||||||
</template>
|
</template>
|
||||||
<template v-else>{{ chapter.title || 'Sans titre' }}</template>
|
<template v-else>{{ chapter.title || 'Sans titre' }}</template>
|
||||||
</router-link>
|
</router-link>
|
||||||
<span v-else class="text-gray-500 dark:text-gray-400">
|
<span v-else class="text-gray-500 dark:text-gray-400">
|
||||||
<template v-if="chapter.isVolumeGroup">
|
<template v-if="chapter.isVolumeGroup && chapter.volumeChapterCount > 1">
|
||||||
{{ chapter.volumeChapterCount > 1 ? 'Chapitres ' : 'Chapitre ' }}{{ chapter.volumeChaptersRange }}
|
Chapitres {{ chapter.volumeChaptersRange }}
|
||||||
</template>
|
</template>
|
||||||
<template v-else>{{ chapter.title || 'Sans titre' }}</template>
|
<template v-else>{{ chapter.title || 'Sans titre' }}</template>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ import ReaderPage from './ReaderPage.vue';
|
|||||||
visibilityObserver.value?.disconnect();
|
visibilityObserver.value?.disconnect();
|
||||||
|
|
||||||
observer.value = new IntersectionObserver(observeIntersection, {
|
observer.value = new IntersectionObserver(observeIntersection, {
|
||||||
root: null,
|
root: containerRef.value,
|
||||||
threshold: 0.5
|
threshold: 0.5
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -124,7 +124,7 @@ import ReaderPage from './ReaderPage.vue';
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
{ root: null, rootMargin: '1000px 0px', threshold: 0 }
|
{ root: containerRef.value, rootMargin: '1000px 0px', threshold: 0 }
|
||||||
);
|
);
|
||||||
|
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
@@ -328,7 +328,6 @@ import ReaderPage from './ReaderPage.vue';
|
|||||||
@apply flex-1 flex flex-col items-center overflow-y-auto relative min-h-0;
|
@apply flex-1 flex flex-col items-center overflow-y-auto relative min-h-0;
|
||||||
/* Réduction du padding sur mobile */
|
/* Réduction du padding sur mobile */
|
||||||
@apply py-2 sm:py-8;
|
@apply py-2 sm:py-8;
|
||||||
scroll-behavior: smooth;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-wrapper {
|
.page-wrapper {
|
||||||
|
|||||||
@@ -242,8 +242,17 @@ watch(() => props.source, (newSource) => {
|
|||||||
}
|
}
|
||||||
}, { immediate: true });
|
}, { immediate: true });
|
||||||
|
|
||||||
|
const buildPayload = (formData) => {
|
||||||
|
const data = { ...formData };
|
||||||
|
const raw = data.testChapterNumber;
|
||||||
|
data.testChapterNumber = (raw === '' || raw === null || raw === undefined)
|
||||||
|
? null
|
||||||
|
: parseFloat(raw);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
emit('submit', { ...form.value });
|
emit('submit', buildPayload(form.value));
|
||||||
};
|
};
|
||||||
|
|
||||||
defineExpose({ submitForm: handleSubmit });
|
defineExpose({ submitForm: handleSubmit });
|
||||||
@@ -252,7 +261,7 @@ const testConfiguration = async () => {
|
|||||||
testing.value = true;
|
testing.value = true;
|
||||||
try {
|
try {
|
||||||
await emit('test', {
|
await emit('test', {
|
||||||
configuration: { ...form.value },
|
configuration: buildPayload(form.value),
|
||||||
testData: {
|
testData: {
|
||||||
mangaSlug: form.value.testSlug,
|
mangaSlug: form.value.testSlug,
|
||||||
chapterNumber: form.value.testChapterNumber,
|
chapterNumber: form.value.testChapterNumber,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="h-screen overflow-hidden bg-gray-50 dark:bg-gray-900 flex">
|
<div class="h-[100dvh] overflow-hidden bg-gray-50 dark:bg-gray-900 flex">
|
||||||
<Header
|
<Header
|
||||||
:show-menu-button="isReaderMode"
|
:show-menu-button="isReaderMode"
|
||||||
@menu-click="toggleSidebar"
|
@menu-click="toggleSidebar"
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
headerStore.shouldShowHeader ? 'mt-16' : 'mt-0',
|
headerStore.shouldShowHeader ? 'mt-16' : 'mt-0',
|
||||||
isReaderMode ? '' : 'md:ml-60'
|
isReaderMode ? '' : 'md:ml-60'
|
||||||
]" style="transition: margin-top 300ms ease-in-out;">
|
]" style="transition: margin-top 300ms ease-in-out;">
|
||||||
<RouterView></RouterView>
|
<RouterView class="flex-1 min-h-0"></RouterView>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -3,57 +3,50 @@
|
|||||||
"type": "project",
|
"type": "project",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"description": "A minimal Symfony project recommended to create bare bones applications",
|
"description": "A minimal Symfony project recommended to create bare bones applications",
|
||||||
"minimum-stability": "stable",
|
"minimum-stability": "dev",
|
||||||
"prefer-stable": true,
|
"prefer-stable": true,
|
||||||
"require": {
|
"require": {
|
||||||
"php": ">=8.3.1",
|
"php": ">=8.4.0",
|
||||||
"ext-ctype": "*",
|
"ext-ctype": "*",
|
||||||
"ext-curl": "*",
|
"ext-curl": "*",
|
||||||
"ext-gd": "*",
|
"ext-gd": "*",
|
||||||
"ext-iconv": "*",
|
"ext-iconv": "*",
|
||||||
"ext-zip": "*",
|
"ext-zip": "*",
|
||||||
"api-platform/core": "^3.2",
|
"api-platform/core": "^4.0",
|
||||||
"doctrine/dbal": "^3",
|
"doctrine/dbal": "^4",
|
||||||
"doctrine/doctrine-bundle": "^2.11",
|
"doctrine/doctrine-bundle": "^3.0",
|
||||||
"doctrine/doctrine-migrations-bundle": "^3.3",
|
"doctrine/doctrine-migrations-bundle": "^3.3",
|
||||||
"doctrine/orm": "^2.17",
|
"doctrine/orm": "^3.0",
|
||||||
"guzzlehttp/guzzle": "^7.8",
|
"guzzlehttp/guzzle": "^7.8",
|
||||||
"intervention/image": "^3.7",
|
"intervention/image": "^3.7",
|
||||||
"nelmio/cors-bundle": "^2.4",
|
"nelmio/cors-bundle": "^2.4",
|
||||||
"phpdocumentor/reflection-docblock": "^5.3",
|
"phpdocumentor/reflection-docblock": "^5.3",
|
||||||
"phpstan/phpdoc-parser": "^1.25",
|
"phpstan/phpdoc-parser": "^1.25",
|
||||||
"ramsey/uuid": "^4.7",
|
"ramsey/uuid": "^4.7",
|
||||||
"runtime/frankenphp-symfony": "^0.2.0",
|
"symfony/asset": "8.0.*",
|
||||||
"symfony/asset": "7.0.*",
|
"symfony/console": "8.0.*",
|
||||||
"symfony/console": "7.0.*",
|
"symfony/css-selector": "8.0.*",
|
||||||
"symfony/css-selector": "7.0.*",
|
"symfony/doctrine-messenger": "8.0.*",
|
||||||
"symfony/doctrine-messenger": "7.0.*",
|
"symfony/dotenv": "8.0.*",
|
||||||
"symfony/dotenv": "7.0.*",
|
"symfony/expression-language": "8.0.*",
|
||||||
"symfony/expression-language": "7.0.*",
|
|
||||||
"symfony/flex": "^2",
|
"symfony/flex": "^2",
|
||||||
"symfony/form": "7.0.*",
|
"symfony/framework-bundle": "8.0.*",
|
||||||
"symfony/framework-bundle": "7.0.*",
|
"symfony/http-client": "8.0.*",
|
||||||
"symfony/http-client": "7.0.*",
|
"symfony/mercure-bundle": "^0.4",
|
||||||
"symfony/mercure-bundle": "^0.3.9",
|
"symfony/messenger": "8.0.*",
|
||||||
"symfony/messenger": "7.0.*",
|
"symfony/mime": "8.0.*",
|
||||||
"symfony/mime": "7.0.*",
|
"symfony/monolog-bundle": "^4.0",
|
||||||
"symfony/monolog-bundle": "^3.10",
|
|
||||||
"symfony/panther": "^2.1",
|
"symfony/panther": "^2.1",
|
||||||
"symfony/property-access": "7.0.*",
|
"symfony/property-access": "8.0.*",
|
||||||
"symfony/property-info": "7.0.*",
|
"symfony/property-info": "8.0.*",
|
||||||
"symfony/runtime": "7.0.*",
|
"symfony/runtime": "8.0.*",
|
||||||
"symfony/scheduler": "7.0.*",
|
"symfony/scheduler": "8.0.*",
|
||||||
"symfony/security-bundle": "7.0.*",
|
"symfony/security-bundle": "8.0.*",
|
||||||
"symfony/serializer": "7.0.*",
|
"symfony/serializer": "8.0.*",
|
||||||
"symfony/stimulus-bundle": "^2.17",
|
"symfony/twig-bundle": "8.0.*",
|
||||||
"symfony/twig-bundle": "7.0.*",
|
"symfony/validator": "8.0.*",
|
||||||
"symfony/ux-live-component": "^2.17",
|
|
||||||
"symfony/ux-react": "^2.23",
|
|
||||||
"symfony/ux-turbo": "^2.18",
|
|
||||||
"symfony/validator": "7.0.*",
|
|
||||||
"symfony/webpack-encore-bundle": "^2.1",
|
"symfony/webpack-encore-bundle": "^2.1",
|
||||||
"symfony/yaml": "7.0.*",
|
"symfony/yaml": "8.0.*",
|
||||||
"twig/extra-bundle": "^2.12|^3.0",
|
|
||||||
"twig/twig": "^2.12|^3.0",
|
"twig/twig": "^2.12|^3.0",
|
||||||
"vich/uploader-bundle": "^2.7"
|
"vich/uploader-bundle": "^2.7"
|
||||||
},
|
},
|
||||||
@@ -103,7 +96,7 @@
|
|||||||
"extra": {
|
"extra": {
|
||||||
"symfony": {
|
"symfony": {
|
||||||
"allow-contrib": false,
|
"allow-contrib": false,
|
||||||
"require": "7.0.*",
|
"require": "8.0.*",
|
||||||
"docker": true
|
"docker": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -111,18 +104,18 @@
|
|||||||
"dama/doctrine-test-bundle": "^8.2",
|
"dama/doctrine-test-bundle": "^8.2",
|
||||||
"dbrekelmans/bdi": "^1.3",
|
"dbrekelmans/bdi": "^1.3",
|
||||||
"deployer/deployer": "^7.5",
|
"deployer/deployer": "^7.5",
|
||||||
"doctrine/doctrine-fixtures-bundle": "^3.5",
|
"doctrine/doctrine-fixtures-bundle": "^4.0",
|
||||||
"friendsofphp/php-cs-fixer": "^3.48",
|
"friendsofphp/php-cs-fixer": "^3.48",
|
||||||
"mtdowling/jmespath.php": "^2.7",
|
"mtdowling/jmespath.php": "^2.7",
|
||||||
"phparkitect/phparkitect": "^0.3.33",
|
"phparkitect/phparkitect": "^0.8",
|
||||||
"phpmd/phpmd": "^2.15",
|
"phpmd/phpmd": "3.x-dev",
|
||||||
"phpunit/phpunit": "^10.5",
|
"phpunit/phpunit": "^10.5",
|
||||||
"symfony/browser-kit": "7.0.*",
|
"symfony/browser-kit": "8.0.*",
|
||||||
"symfony/maker-bundle": "^1.52",
|
"symfony/maker-bundle": "^1.52",
|
||||||
"symfony/phpunit-bridge": "^7.0",
|
"symfony/phpunit-bridge": "^8.0",
|
||||||
"symfony/stopwatch": "7.0.*",
|
"symfony/stopwatch": "8.0.*",
|
||||||
"symfony/web-profiler-bundle": "7.0.*",
|
"symfony/web-profiler-bundle": "8.0.*",
|
||||||
"zenstruck/browser": "^1.8",
|
"zenstruck/browser": "^1.8",
|
||||||
"zenstruck/foundry": "^1.36"
|
"zenstruck/foundry": "^2.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
4313
composer.lock
generated
4313
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -14,13 +14,7 @@ return [
|
|||||||
Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
|
Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
|
||||||
Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true],
|
Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true],
|
||||||
Symfony\WebpackEncoreBundle\WebpackEncoreBundle::class => ['all' => true],
|
Symfony\WebpackEncoreBundle\WebpackEncoreBundle::class => ['all' => true],
|
||||||
Symfony\UX\TwigComponent\TwigComponentBundle::class => ['all' => true],
|
|
||||||
Symfony\UX\LiveComponent\LiveComponentBundle::class => ['all' => true],
|
|
||||||
Symfony\UX\StimulusBundle\StimulusBundle::class => ['all' => true],
|
|
||||||
Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true],
|
|
||||||
Symfony\Bundle\MercureBundle\MercureBundle::class => ['all' => true],
|
Symfony\Bundle\MercureBundle\MercureBundle::class => ['all' => true],
|
||||||
Symfony\UX\Turbo\TurboBundle::class => ['all' => true],
|
|
||||||
DAMA\DoctrineTestBundle\DAMADoctrineTestBundle::class => ['test' => true],
|
DAMA\DoctrineTestBundle\DAMADoctrineTestBundle::class => ['test' => true],
|
||||||
Symfony\UX\React\ReactBundle::class => ['all' => true],
|
|
||||||
Vich\UploaderBundle\VichUploaderBundle::class => ['all' => true],
|
Vich\UploaderBundle\VichUploaderBundle::class => ['all' => true],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -23,8 +23,6 @@ api_platform:
|
|||||||
extra_properties:
|
extra_properties:
|
||||||
standard_put: true
|
standard_put: true
|
||||||
rfc_7807_compliant_errors: true
|
rfc_7807_compliant_errors: true
|
||||||
event_listeners_backward_compatibility_layer: false
|
|
||||||
keep_legacy_inflector: false
|
|
||||||
mapping:
|
mapping:
|
||||||
paths:
|
paths:
|
||||||
- '%kernel.project_dir%/src/Domain/Scraping/Infrastructure/ApiPlatform/Dto'
|
- '%kernel.project_dir%/src/Domain/Scraping/Infrastructure/ApiPlatform/Dto'
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ doctrine:
|
|||||||
connections:
|
connections:
|
||||||
default:
|
default:
|
||||||
url: '%env(resolve:DATABASE_URL)%'
|
url: '%env(resolve:DATABASE_URL)%'
|
||||||
use_savepoints: true
|
|
||||||
profiling_collect_backtrace: '%kernel.debug%'
|
profiling_collect_backtrace: '%kernel.debug%'
|
||||||
|
|
||||||
# IMPORTANT: You MUST configure your server version,
|
# IMPORTANT: You MUST configure your server version,
|
||||||
@@ -11,9 +10,6 @@ doctrine:
|
|||||||
#server_version: '16'
|
#server_version: '16'
|
||||||
|
|
||||||
orm:
|
orm:
|
||||||
auto_generate_proxy_classes: true
|
|
||||||
enable_lazy_ghost_objects: true
|
|
||||||
report_fields_where_declared: true
|
|
||||||
validate_xml_mapping: true
|
validate_xml_mapping: true
|
||||||
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
|
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
|
||||||
auto_mapping: true
|
auto_mapping: true
|
||||||
@@ -40,15 +36,12 @@ when@test:
|
|||||||
dbal:
|
dbal:
|
||||||
connections:
|
connections:
|
||||||
default:
|
default:
|
||||||
use_savepoints: true
|
|
||||||
# "TEST_TOKEN" is typically set by ParaTest
|
# "TEST_TOKEN" is typically set by ParaTest
|
||||||
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
|
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
|
||||||
|
|
||||||
when@prod:
|
when@prod:
|
||||||
doctrine:
|
doctrine:
|
||||||
orm:
|
orm:
|
||||||
auto_generate_proxy_classes: false
|
|
||||||
proxy_dir: '%kernel.build_dir%/doctrine/orm/Proxies'
|
|
||||||
query_cache_driver:
|
query_cache_driver:
|
||||||
type: pool
|
type: pool
|
||||||
pool: doctrine.system_cache_pool
|
pool: doctrine.system_cache_pool
|
||||||
|
|||||||
@@ -37,10 +37,6 @@ framework:
|
|||||||
'App\Domain\Shared\Domain\Event\VolumeImported': events
|
'App\Domain\Shared\Domain\Event\VolumeImported': events
|
||||||
'App\Domain\Shared\Domain\Event\ChapterScraped': events
|
'App\Domain\Shared\Domain\Event\ChapterScraped': events
|
||||||
|
|
||||||
# Legacy messages (à garder si nécessaire)
|
|
||||||
'App\Message\DownloadChapter': commands
|
|
||||||
'App\Message\RefreshMetadata': commands
|
|
||||||
'App\Message\RefreshAndDownloadChapters': commands
|
|
||||||
|
|
||||||
# when@test:
|
# when@test:
|
||||||
# framework:
|
# framework:
|
||||||
|
|||||||
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,5 +0,0 @@
|
|||||||
twig_component:
|
|
||||||
anonymous_template_directory: 'components/'
|
|
||||||
defaults:
|
|
||||||
# Namespace & directory for components
|
|
||||||
App\Twig\Components\: 'components/'
|
|
||||||
2001
config/reference.php
Normal file
2001
config/reference.php
Normal file
File diff suppressed because it is too large
Load Diff
@@ -7,8 +7,3 @@ vue_app:
|
|||||||
requirements:
|
requirements:
|
||||||
req: "^(?!api/|legacy).*"
|
req: "^(?!api/|legacy).*"
|
||||||
|
|
||||||
controllers:
|
|
||||||
resource:
|
|
||||||
path: ../src/Controller/
|
|
||||||
namespace: App\Controller
|
|
||||||
type: attribute
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
live_component:
|
|
||||||
resource: '@LiveComponentBundle/config/routes.php'
|
|
||||||
prefix: '/_components'
|
|
||||||
# adjust prefix to add localization to your components
|
|
||||||
#prefix: '/{_locale}/_components'
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -26,10 +26,6 @@ services:
|
|||||||
# add more service definitions when explicit configuration is needed
|
# add more service definitions when explicit configuration is needed
|
||||||
# please note that last definitions always *replace* previous ones
|
# please note that last definitions always *replace* previous ones
|
||||||
|
|
||||||
App\EventListener\ExceptionListener:
|
|
||||||
tags:
|
|
||||||
- { name: kernel.event_listener, event: kernel.exception, method: onKernelException }
|
|
||||||
|
|
||||||
GuzzleHttp\Client:
|
GuzzleHttp\Client:
|
||||||
class: GuzzleHttp\Client
|
class: GuzzleHttp\Client
|
||||||
arguments:
|
arguments:
|
||||||
@@ -43,63 +39,11 @@ services:
|
|||||||
protocols: [ 'http', 'https' ]
|
protocols: [ 'http', 'https' ]
|
||||||
track_redirects: true
|
track_redirects: true
|
||||||
|
|
||||||
App\Service\MangaScraperService:
|
|
||||||
arguments:
|
|
||||||
$projectDir: '%kernel.project_dir%'
|
|
||||||
|
|
||||||
App\Controller\TestController:
|
|
||||||
arguments:
|
|
||||||
$projectDir: '%kernel.project_dir%'
|
|
||||||
|
|
||||||
App\Domain\Conversion\Infrastructure\Service\ConversionService:
|
App\Domain\Conversion\Infrastructure\Service\ConversionService:
|
||||||
arguments:
|
arguments:
|
||||||
$projectDir: '%kernel.project_dir%'
|
$projectDir: '%kernel.project_dir%'
|
||||||
|
|
||||||
App\Service\CbrToCbzConverter:
|
# Scrapers Factory for Domain Layer
|
||||||
arguments:
|
|
||||||
$projectDir: '%kernel.project_dir%'
|
|
||||||
|
|
||||||
App\Manager\FileSystemManager:
|
|
||||||
arguments:
|
|
||||||
$projectDir: '%kernel.project_dir%'
|
|
||||||
|
|
||||||
App\EventSubscriber\QueueStatusSubscriber:
|
|
||||||
tags:
|
|
||||||
- { name: kernel.event_subscriber }
|
|
||||||
|
|
||||||
App\Client\MangadexClient:
|
|
||||||
arguments:
|
|
||||||
$httpClient: '@GuzzleHttp\Client'
|
|
||||||
$clientId: '%env(MANGADEX_CLIENT_ID)%'
|
|
||||||
$clientSecret: '%env(MANGADEX_CLIENT_SECRET)%'
|
|
||||||
$username: '%env(MANGADEX_USERNAME)%'
|
|
||||||
$password: '%env(MANGADEX_PASSWORD)%'
|
|
||||||
|
|
||||||
App\Service\MangadexProvider:
|
|
||||||
arguments:
|
|
||||||
$client: '@App\Client\MangadexClient'
|
|
||||||
|
|
||||||
# Scraper Service
|
|
||||||
App\Service\Scraper\HtmlScraper:
|
|
||||||
tags: [ 'app.scraper' ]
|
|
||||||
|
|
||||||
App\Service\Scraper\JavascriptScraper:
|
|
||||||
tags: [ 'app.scraper' ]
|
|
||||||
|
|
||||||
App\Service\Scraper\MangadexScraper:
|
|
||||||
tags: [ 'app.scraper' ]
|
|
||||||
|
|
||||||
# Scraper Factory
|
|
||||||
App\Service\Scraper\ScraperFactory:
|
|
||||||
arguments:
|
|
||||||
$scrapers: !tagged_iterator app.scraper
|
|
||||||
|
|
||||||
# Manga Scraper Service
|
|
||||||
App\Service\Scraper\MangaScraperService:
|
|
||||||
arguments:
|
|
||||||
$scraperFactory: '@App\Service\Scraper\ScraperFactory'
|
|
||||||
|
|
||||||
# New Scrapers Factory for Domain Layer
|
|
||||||
App\Domain\Scraping\Infrastructure\Service\ScraperFactory:
|
App\Domain\Scraping\Infrastructure\Service\ScraperFactory:
|
||||||
arguments:
|
arguments:
|
||||||
$projectDir: '%kernel.project_dir%'
|
$projectDir: '%kernel.project_dir%'
|
||||||
@@ -187,20 +131,6 @@ services:
|
|||||||
App\Domain\Scraping\Domain\Contract\Repository\ContentSourceHealthRepositoryInterface:
|
App\Domain\Scraping\Domain\Contract\Repository\ContentSourceHealthRepositoryInterface:
|
||||||
alias: App\Domain\Setting\Infrastructure\Persistence\Repository\DoctrineContentSourceForHealthCheckRepository
|
alias: App\Domain\Setting\Infrastructure\Persistence\Repository\DoctrineContentSourceForHealthCheckRepository
|
||||||
|
|
||||||
# Import Domain Services
|
|
||||||
App\Domain\Import\Infrastructure\Service\FilenameAnalyzer: ~
|
|
||||||
|
|
||||||
App\Domain\Import\Domain\Service\FilenameAnalyzerInterface:
|
|
||||||
alias: App\Domain\Import\Infrastructure\Service\FilenameAnalyzer
|
|
||||||
|
|
||||||
# Import Domain Query/Command Handlers
|
|
||||||
App\Domain\Import\Application\QueryHandler\AnalyzeFilenameQueryHandler: ~
|
|
||||||
App\Domain\Import\Application\CommandHandler\ImportFileCommandHandler: ~
|
|
||||||
|
|
||||||
# Import Domain API Platform Services
|
|
||||||
App\Domain\Import\Infrastructure\ApiPlatform\State\Processor\AnalyzeFilenameStateProcessor: ~
|
|
||||||
App\Domain\Import\Infrastructure\ApiPlatform\State\Processor\ImportFileStateProcessor: ~
|
|
||||||
|
|
||||||
# System Domain
|
# System Domain
|
||||||
App\Domain\System\Domain\Contract\Repository\SystemStatusRepositoryInterface:
|
App\Domain\System\Domain\Contract\Repository\SystemStatusRepositoryInterface:
|
||||||
alias: App\Domain\System\Infrastructure\Persistence\Repository\DoctrineSystemStatusRepository
|
alias: App\Domain\System\Infrastructure\Persistence\Repository\DoctrineSystemStatusRepository
|
||||||
|
|||||||
27
deploy.php
27
deploy.php
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Deployer;
|
namespace Deployer;
|
||||||
|
|
||||||
require 'recipe/symfony.php';
|
require 'recipe/symfony.php';
|
||||||
@@ -33,15 +34,16 @@ task('deploy:prepare_dirs', function () {
|
|||||||
// --user assure que vendor/ appartient au user deploy et non root
|
// --user assure que vendor/ appartient au user deploy et non root
|
||||||
// Skip si composer.lock inchangé et vendor/ déjà populé (hard-linké depuis la release précédente)
|
// Skip si composer.lock inchangé et vendor/ déjà populé (hard-linké depuis la release précédente)
|
||||||
task('deploy:vendors', function () {
|
task('deploy:vendors', function () {
|
||||||
$releaseDir = get('release_path');
|
$releaseDir = get('release_path');
|
||||||
$previousDir = get('previous_release');
|
$previousDir = get('previous_release');
|
||||||
|
|
||||||
if ($previousDir !== null) {
|
if (null !== $previousDir) {
|
||||||
$lockUnchanged = test("diff -q $previousDir/composer.lock $releaseDir/composer.lock > /dev/null 2>&1");
|
$lockUnchanged = test("diff -q $previousDir/composer.lock $releaseDir/composer.lock > /dev/null 2>&1");
|
||||||
$vendorPopulated = test("[ -d $releaseDir/vendor/composer ]");
|
$vendorPopulated = test("[ -d $releaseDir/vendor/composer ]");
|
||||||
|
|
||||||
if ($lockUnchanged && $vendorPopulated) {
|
if ($lockUnchanged && $vendorPopulated) {
|
||||||
writeln('<info>deploy:vendors skipped — composer.lock unchanged</info>');
|
writeln('<info>deploy:vendors skipped — composer.lock unchanged</info>');
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -56,23 +58,23 @@ task('deploy:vendors', function () {
|
|||||||
// 3. Cache npm et webpack persistants entre les releases
|
// 3. Cache npm et webpack persistants entre les releases
|
||||||
desc('Build Webpack Encore assets');
|
desc('Build Webpack Encore assets');
|
||||||
task('webpack_encore:build', function () {
|
task('webpack_encore:build', function () {
|
||||||
$sharedDir = '/srv/mangarr/shared';
|
$sharedDir = '/srv/mangarr/shared';
|
||||||
$sharedWebpackCache = "$sharedDir/webpack_cache";
|
$sharedWebpackCache = "$sharedDir/webpack_cache";
|
||||||
$sharedNodeModules = "$sharedDir/node_modules";
|
$sharedNodeModules = "$sharedDir/node_modules";
|
||||||
$sharedNpmCache = "$sharedDir/npm_cache";
|
$sharedNpmCache = "$sharedDir/npm_cache";
|
||||||
|
|
||||||
run("mkdir -p $sharedWebpackCache $sharedNodeModules $sharedNpmCache");
|
run("mkdir -p $sharedWebpackCache $sharedNodeModules $sharedNpmCache");
|
||||||
|
|
||||||
$releaseDir = get('release_path');
|
$releaseDir = get('release_path');
|
||||||
$previousDir = get('previous_release'); // null au 1er déploiement
|
$previousDir = get('previous_release'); // null au 1er déploiement
|
||||||
|
|
||||||
// --- COUCHE 1 : skip total si aucun fichier front-end n'a changé ---
|
// --- COUCHE 1 : skip total si aucun fichier front-end n'a changé ---
|
||||||
if ($previousDir !== null) {
|
if (null !== $previousDir) {
|
||||||
$watchList = ['assets', 'templates', 'package.json', 'package-lock.json',
|
$watchList = ['assets', 'templates', 'package.json', 'package-lock.json',
|
||||||
'webpack.config.js', 'postcss.config.js', 'tailwind.config.js'];
|
'webpack.config.js', 'postcss.config.js', 'tailwind.config.js'];
|
||||||
|
|
||||||
$diffChecks = implode(' && ', array_map(
|
$diffChecks = implode(' && ', array_map(
|
||||||
fn($p) => "diff -rq --no-dereference $previousDir/$p $releaseDir/$p > /dev/null 2>&1",
|
fn ($p) => "diff -rq --no-dereference $previousDir/$p $releaseDir/$p > /dev/null 2>&1",
|
||||||
$watchList
|
$watchList
|
||||||
));
|
));
|
||||||
|
|
||||||
@@ -81,15 +83,16 @@ task('webpack_encore:build', function () {
|
|||||||
if ($hasPreviousBuild && test("($diffChecks)")) {
|
if ($hasPreviousBuild && test("($diffChecks)")) {
|
||||||
run("cp -al $previousDir/public/build $releaseDir/public/build");
|
run("cp -al $previousDir/public/build $releaseDir/public/build");
|
||||||
writeln('<info>webpack_encore:build skipped — no front-end files changed</info>');
|
writeln('<info>webpack_encore:build skipped — no front-end files changed</info>');
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- COUCHE 2 : skip npm install si package-lock.json inchangé ---
|
// --- COUCHE 2 : skip npm install si package-lock.json inchangé ---
|
||||||
$needsNpmInstall = true;
|
$needsNpmInstall = true;
|
||||||
if ($previousDir !== null) {
|
if (null !== $previousDir) {
|
||||||
$lockUnchanged = test("diff -q $previousDir/package-lock.json $releaseDir/package-lock.json > /dev/null 2>&1");
|
$lockUnchanged = test("diff -q $previousDir/package-lock.json $releaseDir/package-lock.json > /dev/null 2>&1");
|
||||||
$nmPopulated = test("[ -d $sharedNodeModules/.bin ]");
|
$nmPopulated = test("[ -d $sharedNodeModules/.bin ]");
|
||||||
if ($lockUnchanged && $nmPopulated) {
|
if ($lockUnchanged && $nmPopulated) {
|
||||||
$needsNpmInstall = false;
|
$needsNpmInstall = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,13 @@ if [ "$1" = 'frankenphp' ] || [ "$1" = 'php' ] || [ "$1" = 'bin/console' ]; then
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Vider le cache prod stale avant le démarrage des workers FrankenPHP.
|
||||||
|
# Sans ça, les workers chargent l'ancien cache du volume Docker et crashent
|
||||||
|
# en boucle si les classes du cache ne correspondent plus à la version déployée.
|
||||||
|
if [ "$APP_ENV" = "prod" ]; then
|
||||||
|
rm -rf var/cache/prod
|
||||||
|
fi
|
||||||
|
|
||||||
setfacl -R -m u:www-data:rwX -m u:"$(whoami)":rwX var
|
setfacl -R -m u:www-data:rwX -m u:"$(whoami)":rwX var
|
||||||
setfacl -dR -m u:www-data:rwX -m u:"$(whoami)":rwX var
|
setfacl -dR -m u:www-data:rwX -m u:"$(whoami)":rwX var
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
worker {
|
worker {
|
||||||
file ./public/index.php
|
file ./public/index.php
|
||||||
env APP_RUNTIME Runtime\FrankenPhpSymfony\Runtime
|
num 2
|
||||||
}
|
}
|
||||||
|
|||||||
97
migrations/Version20260326165659.php
Normal file
97
migrations/Version20260326165659.php
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
<?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 Version20260326165659 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Migrate manga.genres column from PHP-serialized array to JSON';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function preUp(Schema $schema): void
|
||||||
|
{
|
||||||
|
// Convert existing PHP-serialized data to JSON before changing the column type
|
||||||
|
$rows = $this->connection->fetchAllAssociative('SELECT id, genres FROM manga WHERE genres IS NOT NULL');
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$raw = $row['genres'];
|
||||||
|
// Skip if already valid JSON
|
||||||
|
json_decode($raw);
|
||||||
|
if (json_last_error() === JSON_ERROR_NONE) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Unserialize PHP format and re-encode as JSON
|
||||||
|
$value = @unserialize($raw);
|
||||||
|
if ($value === false && $raw !== 'b:0;') {
|
||||||
|
$value = [];
|
||||||
|
}
|
||||||
|
$this->connection->executeStatement(
|
||||||
|
'UPDATE manga SET genres = :json WHERE id = :id',
|
||||||
|
['json' => json_encode($value), 'id' => $row['id']]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('COMMENT ON COLUMN api_token.expires_at IS \'\'');
|
||||||
|
$this->addSql('COMMENT ON COLUMN content_source.health_last_tested_at IS \'\'');
|
||||||
|
$this->addSql('COMMENT ON COLUMN failed_job.failed_at IS \'\'');
|
||||||
|
$this->addSql('COMMENT ON COLUMN job.created_at IS \'\'');
|
||||||
|
$this->addSql('COMMENT ON COLUMN job.started_at IS \'\'');
|
||||||
|
$this->addSql('COMMENT ON COLUMN job.completed_at IS \'\'');
|
||||||
|
$this->addSql('ALTER TABLE manga ALTER genres TYPE JSON USING genres::json');
|
||||||
|
$this->addSql('COMMENT ON COLUMN manga.genres IS \'\'');
|
||||||
|
$this->addSql('COMMENT ON COLUMN manga.created_at IS \'\'');
|
||||||
|
$this->addSql('COMMENT ON COLUMN manga.last_monitoring_check IS \'\'');
|
||||||
|
$this->addSql('COMMENT ON COLUMN manga_preferred_sources.created_at IS \'\'');
|
||||||
|
$this->addSql('COMMENT ON COLUMN manga_preferred_sources.updated_at IS \'\'');
|
||||||
|
$this->addSql('COMMENT ON COLUMN source.created_at IS \'\'');
|
||||||
|
$this->addSql('COMMENT ON COLUMN source.updated_at IS \'\'');
|
||||||
|
$this->addSql('DROP INDEX idx_75ea56e0e3bd61ce');
|
||||||
|
$this->addSql('DROP INDEX idx_75ea56e0fb7336f0');
|
||||||
|
$this->addSql('DROP INDEX idx_75ea56e016ba31db');
|
||||||
|
$this->addSql('ALTER TABLE messenger_messages ALTER id DROP DEFAULT');
|
||||||
|
$this->addSql('ALTER TABLE messenger_messages ALTER id ADD GENERATED BY DEFAULT AS IDENTITY');
|
||||||
|
$this->addSql('COMMENT ON COLUMN messenger_messages.created_at IS \'\'');
|
||||||
|
$this->addSql('COMMENT ON COLUMN messenger_messages.available_at IS \'\'');
|
||||||
|
$this->addSql('COMMENT ON COLUMN messenger_messages.delivered_at IS \'\'');
|
||||||
|
$this->addSql('CREATE INDEX IDX_75EA56E0FB7336F0E3BD61CE16BA31DBBF396750 ON messenger_messages (queue_name, available_at, delivered_at, id)');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this down() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('COMMENT ON COLUMN api_token.expires_at IS \'(DC2Type:datetime_immutable)\'');
|
||||||
|
$this->addSql('COMMENT ON COLUMN content_source.health_last_tested_at IS \'(DC2Type:datetime_immutable)\'');
|
||||||
|
$this->addSql('COMMENT ON COLUMN failed_job.failed_at IS \'(DC2Type:datetime_immutable)\'');
|
||||||
|
$this->addSql('COMMENT ON COLUMN job.created_at IS \'(DC2Type:datetime_immutable)\'');
|
||||||
|
$this->addSql('COMMENT ON COLUMN job.started_at IS \'(DC2Type:datetime_immutable)\'');
|
||||||
|
$this->addSql('COMMENT ON COLUMN job.completed_at IS \'(DC2Type:datetime_immutable)\'');
|
||||||
|
$this->addSql('ALTER TABLE manga ALTER genres TYPE TEXT');
|
||||||
|
$this->addSql('COMMENT ON COLUMN manga.genres IS \'(DC2Type:array)\'');
|
||||||
|
$this->addSql('COMMENT ON COLUMN manga.created_at IS \'(DC2Type:datetime_immutable)\'');
|
||||||
|
$this->addSql('COMMENT ON COLUMN manga.last_monitoring_check IS \'(DC2Type:datetime_immutable)\'');
|
||||||
|
$this->addSql('COMMENT ON COLUMN manga_preferred_sources.created_at IS \'(DC2Type:datetime_immutable)\'');
|
||||||
|
$this->addSql('COMMENT ON COLUMN manga_preferred_sources.updated_at IS \'(DC2Type:datetime_immutable)\'');
|
||||||
|
$this->addSql('DROP INDEX IDX_75EA56E0FB7336F0E3BD61CE16BA31DBBF396750');
|
||||||
|
$this->addSql('ALTER TABLE messenger_messages ALTER id SET DEFAULT nextval(\'messenger_messages_id_seq\'::regclass)');
|
||||||
|
$this->addSql('ALTER TABLE messenger_messages ALTER id DROP IDENTITY');
|
||||||
|
$this->addSql('COMMENT ON COLUMN messenger_messages.created_at IS \'(DC2Type:datetime_immutable)\'');
|
||||||
|
$this->addSql('COMMENT ON COLUMN messenger_messages.available_at IS \'(DC2Type:datetime_immutable)\'');
|
||||||
|
$this->addSql('COMMENT ON COLUMN messenger_messages.delivered_at IS \'(DC2Type:datetime_immutable)\'');
|
||||||
|
$this->addSql('CREATE INDEX idx_75ea56e0e3bd61ce ON messenger_messages (available_at)');
|
||||||
|
$this->addSql('CREATE INDEX idx_75ea56e0fb7336f0 ON messenger_messages (queue_name)');
|
||||||
|
$this->addSql('CREATE INDEX idx_75ea56e016ba31db ON messenger_messages (delivered_at)');
|
||||||
|
$this->addSql('COMMENT ON COLUMN source.created_at IS \'(DC2Type:datetime_immutable)\'');
|
||||||
|
$this->addSql('COMMENT ON COLUMN source.updated_at IS \'(DC2Type:datetime_immutable)\'');
|
||||||
|
}
|
||||||
|
}
|
||||||
3506
package-lock.json
generated
3506
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
19
package.json
19
package.json
@@ -2,26 +2,15 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.17.0",
|
"@babel/core": "^7.17.0",
|
||||||
"@babel/preset-env": "^7.16.0",
|
"@babel/preset-env": "^7.16.0",
|
||||||
"@babel/preset-react": "^7.26.3",
|
|
||||||
"@headlessui/vue": "^1.7.23",
|
"@headlessui/vue": "^1.7.23",
|
||||||
"@heroicons/vue": "^2.2.0",
|
"@heroicons/vue": "^2.2.0",
|
||||||
"@hotwired/stimulus": "^3.0.0",
|
|
||||||
"@hotwired/turbo": "^7.1.1 || ^8.0",
|
|
||||||
"@symfony/stimulus-bridge": "^3.2.0",
|
|
||||||
"@symfony/ux-live-component": "file:vendor/symfony/ux-live-component/assets",
|
|
||||||
"@symfony/ux-react": "file:vendor/symfony/ux-react/assets",
|
|
||||||
"@symfony/ux-turbo": "file:vendor/symfony/ux-turbo/assets",
|
|
||||||
"@symfony/webpack-encore": "^4.0.0",
|
"@symfony/webpack-encore": "^4.0.0",
|
||||||
"@vue/compiler-sfc": "^3.5.13",
|
"@vue/compiler-sfc": "^3.5.13",
|
||||||
"core-js": "^3.23.0",
|
"core-js": "^3.23.0",
|
||||||
"daisyui": "^4.4.2",
|
|
||||||
"pinia": "^3.0.1",
|
"pinia": "^3.0.1",
|
||||||
"react": "^18.0",
|
|
||||||
"react-dom": "^18.0",
|
|
||||||
"regenerator-runtime": "^0.13.9",
|
"regenerator-runtime": "^0.13.9",
|
||||||
"sass": "^1.59.3",
|
"sass": "^1.59.3",
|
||||||
"sass-loader": "^13.2.0",
|
"sass-loader": "^13.2.0",
|
||||||
"stimulus-use": "^0.52.2",
|
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
"vue-loader": "^17.4.2",
|
"vue-loader": "^17.4.2",
|
||||||
"vue-router": "^4.5.0",
|
"vue-router": "^4.5.0",
|
||||||
@@ -41,18 +30,12 @@
|
|||||||
"@fortawesome/fontawesome-free": "^6.5.2",
|
"@fortawesome/fontawesome-free": "^6.5.2",
|
||||||
"@fortawesome/fontawesome-svg-core": "^6.7.2",
|
"@fortawesome/fontawesome-svg-core": "^6.7.2",
|
||||||
"@fortawesome/free-solid-svg-icons": "^6.7.2",
|
"@fortawesome/free-solid-svg-icons": "^6.7.2",
|
||||||
"@fortawesome/react-fontawesome": "^0.2.2",
|
|
||||||
"@tanstack/vue-query": "^5.71.0",
|
"@tanstack/vue-query": "^5.71.0",
|
||||||
"alpinejs": "^3.13.3",
|
|
||||||
"autoprefixer": "^10.4.14",
|
"autoprefixer": "^10.4.14",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
"bootstrap": "^5.3.3",
|
|
||||||
"postcss-loader": "^7.1.0",
|
"postcss-loader": "^7.1.0",
|
||||||
"puppeteer": "^22.10.0",
|
"puppeteer": "^22.10.0",
|
||||||
"react-router-dom": "^7.1.5",
|
|
||||||
"sortablejs": "^1.15.2",
|
|
||||||
"tailwindcss": "^3.2.7",
|
"tailwindcss": "^3.2.7",
|
||||||
"vue-i18n": "^11.3.0",
|
"vue-i18n": "^11.3.0"
|
||||||
"vuedraggable": "^2.24.3"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use Arkitect\Expression\ForClasses\ResideInOneOfTheseNamespaces;
|
|||||||
use Arkitect\Rules\Rule;
|
use Arkitect\Rules\Rule;
|
||||||
|
|
||||||
return static function (Config $config): void {
|
return static function (Config $config): void {
|
||||||
$domainClassSet = ClassSet::fromDir(__DIR__ . '/src/Domain');
|
$domainClassSet = ClassSet::fromDir(__DIR__.'/src/Domain');
|
||||||
$businessDomains = ['Manga', 'Reader', 'Scraping', 'Conversion'];
|
$businessDomains = ['Manga', 'Reader', 'Scraping', 'Conversion'];
|
||||||
|
|
||||||
// Classes PHP standards et utilitaires
|
// Classes PHP standards et utilitaires
|
||||||
@@ -29,7 +29,7 @@ return static function (Config $config): void {
|
|||||||
// Dépendances externes autorisées
|
// Dépendances externes autorisées
|
||||||
$externalDependencies = [
|
$externalDependencies = [
|
||||||
'Symfony\Component\Messenger',
|
'Symfony\Component\Messenger',
|
||||||
'Ramsey\Uuid'
|
'Ramsey\Uuid',
|
||||||
];
|
];
|
||||||
|
|
||||||
// Règle pour le namespace cohérent
|
// Règle pour le namespace cohérent
|
||||||
@@ -72,7 +72,7 @@ return static function (Config $config): void {
|
|||||||
// Interdiction explicite pour l'Application d'accéder à l'Infrastructure
|
// Interdiction explicite pour l'Application d'accéder à l'Infrastructure
|
||||||
$rules[] = Rule::allClasses()
|
$rules[] = Rule::allClasses()
|
||||||
->that(new ResideInOneOfTheseNamespaces("App\Domain\\$domain\Application"))
|
->that(new ResideInOneOfTheseNamespaces("App\Domain\\$domain\Application"))
|
||||||
->should(new NotDependsOnTheseNamespaces("App\Domain\\$domain\Infrastructure"))
|
->should(new NotDependsOnTheseNamespaces(["App\Domain\\$domain\Infrastructure"]))
|
||||||
->because("la couche Application de $domain ne doit jamais dépendre de l'Infrastructure, même au sein de son propre domaine");
|
->because("la couche Application de $domain ne doit jamais dépendre de l'Infrastructure, même au sein de son propre domaine");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
0
src/ApiResource/.gitignore
vendored
0
src/ApiResource/.gitignore
vendored
@@ -1,86 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Client;
|
|
||||||
|
|
||||||
use App\Interface\ClientInterface;
|
|
||||||
use GuzzleHttp\ClientInterface as GuzzleInterface;
|
|
||||||
|
|
||||||
class MangadexClient implements ClientInterface
|
|
||||||
{
|
|
||||||
private const AUTHENTICATION_URL = 'https://auth.mangadex.org/realms/mangadex/protocol/openid-connect/token';
|
|
||||||
private const API_URL = 'https://api.mangadex.org';
|
|
||||||
private GuzzleInterface $httpClient;
|
|
||||||
private string $clientId;
|
|
||||||
private string $clientSecret;
|
|
||||||
private string $username;
|
|
||||||
private string $password;
|
|
||||||
private ?string $accessToken = null;
|
|
||||||
private ?string $refreshToken = null;
|
|
||||||
|
|
||||||
public function __construct(GuzzleInterface $httpClient, string $clientId, string $clientSecret, string $username, string $password)
|
|
||||||
{
|
|
||||||
$this->httpClient = $httpClient;
|
|
||||||
$this->clientId = $clientId;
|
|
||||||
$this->clientSecret = $clientSecret;
|
|
||||||
$this->username = $username;
|
|
||||||
$this->password = $password;
|
|
||||||
$this->authenticate();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function authenticate(): void
|
|
||||||
{
|
|
||||||
$response = $this->httpClient->request('POST', self::AUTHENTICATION_URL, [
|
|
||||||
'form_params' => [
|
|
||||||
'grant_type' => 'password',
|
|
||||||
'username' => $this->username,
|
|
||||||
'password' => $this->password,
|
|
||||||
'client_id' => $this->clientId,
|
|
||||||
'client_secret' => $this->clientSecret,
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$data = json_decode($response->getBody()->getContents(), true);
|
|
||||||
$this->accessToken = $data['access_token'];
|
|
||||||
$this->refreshToken = $data['refresh_token'];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function refresh(): void
|
|
||||||
{
|
|
||||||
$response = $this->httpClient->request('POST', self::AUTHENTICATION_URL, [
|
|
||||||
'form_params' => [
|
|
||||||
'grant_type' => 'refresh_token',
|
|
||||||
'refresh_token' => $this->refreshToken,
|
|
||||||
'client_id' => $this->clientId,
|
|
||||||
'client_secret' => $this->clientSecret,
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$data = json_decode($response->getBody()->getContents(), true);
|
|
||||||
$this->accessToken = $data['access_token'];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function request(string $method, string $endpoint, array $options = []): array
|
|
||||||
{
|
|
||||||
$options['headers']['Authorization'] = 'Bearer ' . $this->accessToken;
|
|
||||||
|
|
||||||
$response = $this->httpClient->request($method, self::API_URL . $endpoint, $options);
|
|
||||||
|
|
||||||
if ($response->getStatusCode() === 429) {
|
|
||||||
$this->refresh();
|
|
||||||
$options['headers']['Authorization'] = 'Bearer ' . $this->accessToken;
|
|
||||||
$response = $this->httpClient->request($method, self::API_URL . $endpoint, $options);
|
|
||||||
}
|
|
||||||
|
|
||||||
return json_decode($response->getBody()->getContents(), true);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function get(string $endpoint, array $params = []): array
|
|
||||||
{
|
|
||||||
return $this->request('GET', $endpoint, ['query' => $params]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function post(string $endpoint, array $data): array
|
|
||||||
{
|
|
||||||
return $this->request('POST', $endpoint, ['json' => $data]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
36
src/Command/RunMonitoringCommand.php
Normal file
36
src/Command/RunMonitoringCommand.php
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Command;
|
||||||
|
|
||||||
|
use App\Domain\Manga\Application\Command\CheckMonitoredMangas;
|
||||||
|
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\Messenger\MessageBusInterface;
|
||||||
|
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'app:monitoring:run',
|
||||||
|
description: 'Déclenche immédiatement la vérification des mangas monitorés (sans attendre le scheduler)',
|
||||||
|
)]
|
||||||
|
class RunMonitoringCommand extends Command
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly MessageBusInterface $commandBus,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$output->writeln('Déclenchement du monitoring des mangas...');
|
||||||
|
|
||||||
|
$this->commandBus->dispatch(new CheckMonitoredMangas());
|
||||||
|
|
||||||
|
$output->writeln('<info>Vérification lancée. Les nouveaux chapitres détectés seront scrappés via le worker commands.</info>');
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ use Symfony\Component\Console\Output\OutputInterface;
|
|||||||
class SendTestNotificationCommand extends Command
|
class SendTestNotificationCommand extends Command
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly NotificationInterface $notification
|
private readonly NotificationInterface $notification,
|
||||||
) {
|
) {
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
}
|
}
|
||||||
@@ -38,14 +38,15 @@ class SendTestNotificationCommand extends Command
|
|||||||
$allowed = ['info', 'success', 'error', 'warning'];
|
$allowed = ['info', 'success', 'error', 'warning'];
|
||||||
if (!in_array($type, $allowed, true)) {
|
if (!in_array($type, $allowed, true)) {
|
||||||
$output->writeln(sprintf('<error>Type invalide "%s". Valeurs acceptées : %s</error>', $type, implode(', ', $allowed)));
|
$output->writeln(sprintf('<error>Type invalide "%s". Valeurs acceptées : %s</error>', $type, implode(', ', $allowed)));
|
||||||
|
|
||||||
return Command::FAILURE;
|
return Command::FAILURE;
|
||||||
}
|
}
|
||||||
|
|
||||||
match ($type) {
|
match ($type) {
|
||||||
'success' => $this->notification->sendSuccess($message),
|
'success' => $this->notification->sendSuccess($message),
|
||||||
'error' => $this->notification->sendError($message),
|
'error' => $this->notification->sendError($message),
|
||||||
'warning' => $this->notification->sendWarning($message),
|
'warning' => $this->notification->sendWarning($message),
|
||||||
default => $this->notification->sendInfo($message),
|
default => $this->notification->sendInfo($message),
|
||||||
};
|
};
|
||||||
|
|
||||||
$output->writeln(sprintf('<info>[%s] Notification envoyée : %s</info>', strtoupper($type), $message));
|
$output->writeln(sprintf('<info>[%s] Notification envoyée : %s</info>', strtoupper($type), $message));
|
||||||
|
|||||||
@@ -1,120 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Controller;
|
|
||||||
|
|
||||||
use App\Manager\Toolbar\Factory\ToolbarFactory;
|
|
||||||
use App\Manager\ToolbarManager;
|
|
||||||
use App\Message\DownloadChapter;
|
|
||||||
use App\Repository\ChapterRepository;
|
|
||||||
use Doctrine\DBAL\Connection;
|
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
|
||||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
|
||||||
use Symfony\Component\Messenger\Envelope;
|
|
||||||
use Symfony\Component\Routing\Annotation\Route;
|
|
||||||
|
|
||||||
class ActivityController extends AbstractController
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private readonly Connection $connection,
|
|
||||||
private readonly ChapterRepository $chapterRepository,
|
|
||||||
private readonly ToolbarFactory $toolbarFactory
|
|
||||||
) {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Route('/activity', name: 'app_activity')]
|
|
||||||
public function index(): Response
|
|
||||||
{
|
|
||||||
$queueStatus = $this->getQueueStatus();
|
|
||||||
$decodedPending = $this->decodeMessages($queueStatus['pending']);
|
|
||||||
$decodedProcessing = $this->decodeMessages($queueStatus['processing']);
|
|
||||||
|
|
||||||
$status = array_merge(
|
|
||||||
$this->buildStatusActivity($decodedPending),
|
|
||||||
$this->buildStatusActivity($decodedProcessing)
|
|
||||||
);
|
|
||||||
|
|
||||||
return $this->render('activity/index.html.twig', [
|
|
||||||
'controller_name' => 'ActivityController',
|
|
||||||
'status' => $status,
|
|
||||||
'toolbar' => $this->toolbarFactory->createToolbar('activity')->getGroups(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Route('/activity/status', name: 'app_activity_status', methods: ['GET'])]
|
|
||||||
public function getStatus(): JsonResponse
|
|
||||||
{
|
|
||||||
$queueStatus = $this->getQueueStatus();
|
|
||||||
$decodedPending = $this->decodeMessages($queueStatus['pending']);
|
|
||||||
$decodedProcessing = $this->decodeMessages($queueStatus['processing']);
|
|
||||||
$status = array_merge(
|
|
||||||
$this->buildStatusActivity($decodedPending),
|
|
||||||
$this->buildStatusActivity($decodedProcessing)
|
|
||||||
);
|
|
||||||
|
|
||||||
return new JsonResponse($status);
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO refactorer ce code avec celui du QueueStatusSubscriber
|
|
||||||
private function getQueueStatus(): array
|
|
||||||
{
|
|
||||||
// Requête pour récupérer les messages en attente
|
|
||||||
$sqlPending = 'SELECT * FROM messenger_messages WHERE queue_name = :queue AND available_at IS NULL';
|
|
||||||
$pending = $this->connection->fetchAllAssociative($sqlPending, ['queue' => 'default']);
|
|
||||||
|
|
||||||
// Requête pour récupérer les messages en cours de traitement
|
|
||||||
$sqlProcessing = 'SELECT * FROM messenger_messages WHERE queue_name = :queue AND available_at IS NOT NULL';
|
|
||||||
$processing = $this->connection->fetchAllAssociative($sqlProcessing, ['queue' => 'default']);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'pending' => $pending,
|
|
||||||
'processing' => $processing
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function buildStatusActivity(array $activity): array
|
|
||||||
{
|
|
||||||
$status = [];
|
|
||||||
foreach ($activity as $envelope) {
|
|
||||||
$envelope = $envelope['body'];
|
|
||||||
if ($envelope instanceof Envelope) {
|
|
||||||
if (!$envelope->getMessage() instanceof DownloadChapter) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$chapter = $this->chapterRepository->find($envelope->getMessage()->getChapterId());
|
|
||||||
$manga = $chapter->getManga();
|
|
||||||
$status[] = [
|
|
||||||
'manga' => $manga->getTitle(),
|
|
||||||
'volume' => $chapter->getVolume(),
|
|
||||||
'chapter' => $chapter->getNumber(),
|
|
||||||
'chapterId' => $chapter->getId(),
|
|
||||||
'title' => $chapter->getTitle(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $status;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function decodeMessages(array $messages): array
|
|
||||||
{
|
|
||||||
$decodedMessages = [];
|
|
||||||
|
|
||||||
foreach ($messages as $message) {
|
|
||||||
$decodedMessages[] = [
|
|
||||||
'id' => $message['id'],
|
|
||||||
'body' => $this->decodeMessageBody($message['body']),
|
|
||||||
'headers' => json_decode($message['headers'], true),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $decodedMessages;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function decodeMessageBody(string $body)
|
|
||||||
{
|
|
||||||
return unserialize(stripcslashes($body));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Controller;
|
|
||||||
|
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
|
||||||
|
|
||||||
class CalendarController extends AbstractController
|
|
||||||
{
|
|
||||||
#[Route('/calendar', name: 'app_calendar')]
|
|
||||||
public function index(): Response
|
|
||||||
{
|
|
||||||
return $this->render('calendar/index.html.twig', [
|
|
||||||
'controller_name' => 'CalendarController',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Controller;
|
|
||||||
|
|
||||||
use App\Service\CbrToCbzConverter;
|
|
||||||
use App\Service\NotificationService;
|
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
|
||||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
|
||||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
|
||||||
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
|
||||||
use Symfony\Component\Routing\Annotation\Route;
|
|
||||||
|
|
||||||
class ConversionController extends AbstractController
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private readonly CbrToCbzConverter $cbrToCbzConverter,
|
|
||||||
private readonly NotificationService $notificationService
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Route('/convert', name: 'app_convert')]
|
|
||||||
public function convert(Request $request): Response
|
|
||||||
{
|
|
||||||
if ($request->isMethod('POST')) {
|
|
||||||
/** @var UploadedFile $file */
|
|
||||||
$file = $request->files->get('file');
|
|
||||||
|
|
||||||
if ($file && $file->getClientOriginalExtension() === 'cbr') {
|
|
||||||
$originalFileName = pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME);
|
|
||||||
$tempFilePath = $file->getPathname();
|
|
||||||
|
|
||||||
try {
|
|
||||||
$cbzPath = $this->cbrToCbzConverter->convert($tempFilePath);
|
|
||||||
|
|
||||||
$response = new BinaryFileResponse($cbzPath);
|
|
||||||
$response->setContentDisposition(
|
|
||||||
ResponseHeaderBag::DISPOSITION_ATTACHMENT,
|
|
||||||
$originalFileName . '.cbz'
|
|
||||||
);
|
|
||||||
$response->headers->set('Content-Type', 'application/x-cbz');
|
|
||||||
$response->headers->set('Turbo-Visit-Control', 'reload');
|
|
||||||
|
|
||||||
$response->deleteFileAfterSend(true);
|
|
||||||
|
|
||||||
return $response;
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
$this->notificationService->sendUpdate([
|
|
||||||
'status' => 'error',
|
|
||||||
'message' => 'Une erreur est survenue lors de la conversion : ' . $e->getMessage()
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
$this->notificationService->sendUpdate([
|
|
||||||
'status' => 'error',
|
|
||||||
'message' => 'Veuillez sélectionner un fichier CBR valide.'
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->render('conversion/index.html.twig');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,220 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Controller;
|
|
||||||
|
|
||||||
use App\Manager\FileSystemManager;
|
|
||||||
use App\Repository\ChapterRepository;
|
|
||||||
use App\Repository\MangaRepository;
|
|
||||||
use App\Service\CbrToCbzConverter;
|
|
||||||
use App\Service\CbzService;
|
|
||||||
use App\Service\MangaImportService;
|
|
||||||
use App\Service\NotificationService;
|
|
||||||
use Exception;
|
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
|
||||||
use Symfony\Component\HttpFoundation\File\Exception\FileException;
|
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
|
||||||
use Symfony\Component\HttpFoundation\Session\SessionInterface;
|
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
|
||||||
use Symfony\Component\String\Slugger\SluggerInterface;
|
|
||||||
|
|
||||||
class ImportController extends AbstractController
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private readonly FileSystemManager $fileSystemManager,
|
|
||||||
private readonly CbzService $cbzService,
|
|
||||||
private readonly MangaImportService $mangaImportService,
|
|
||||||
private readonly NotificationService $notificationService,
|
|
||||||
private readonly MangaRepository $mangaRepository,
|
|
||||||
private readonly CbrToCbzConverter $cbrToCbzConverter
|
|
||||||
) {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Route('/manga/import', name: 'app_manga_import')]
|
|
||||||
public function index(Request $request, SessionInterface $session): Response
|
|
||||||
{
|
|
||||||
if ($request->isMethod('POST')) {
|
|
||||||
$files = $request->files->get('files');
|
|
||||||
if ($files) {
|
|
||||||
$importFiles = [];
|
|
||||||
foreach ($files as $file) {
|
|
||||||
if ($file && in_array($file->getClientOriginalExtension(), ['cbz', 'cbr'])) {
|
|
||||||
$originalFileName = $file->getClientOriginalName();
|
|
||||||
|
|
||||||
try {
|
|
||||||
$tmpPath = $this->fileSystemManager->moveUploadedFile(
|
|
||||||
$file->getPathname(),
|
|
||||||
$this->fileSystemManager->getUploadsDirectory(),
|
|
||||||
$file->getClientOriginalName()
|
|
||||||
);
|
|
||||||
$importFiles[] = [
|
|
||||||
'id' => uniqid(),
|
|
||||||
'path' => $tmpPath,
|
|
||||||
'original_name' => $originalFileName,
|
|
||||||
];
|
|
||||||
} catch (FileException $e) {
|
|
||||||
$this->notificationService->sendUpdate([
|
|
||||||
'status' => 'error',
|
|
||||||
'message' => 'Une erreur est survenue lors de l\'import du fichier ' . $originalFileName,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
$this->notificationService->sendUpdate([
|
|
||||||
'status' => 'error',
|
|
||||||
'message' => 'Le fichier ' . $file->getClientOriginalName() . ' doit être au format CBZ ou CBR.',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!empty($importFiles)) {
|
|
||||||
$session->set('import_files', $importFiles);
|
|
||||||
return $this->redirectToRoute('import_match');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
$this->notificationService->sendUpdate([
|
|
||||||
'status' => 'error',
|
|
||||||
'message' => 'Aucun fichier n\'a été sélectionné.',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->render('import/index.html.twig');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @throws Exception
|
|
||||||
*/
|
|
||||||
#[Route('/import/match', name: 'import_match')]
|
|
||||||
public function match(SessionInterface $session): Response
|
|
||||||
{
|
|
||||||
$files = $session->get('import_files', []);
|
|
||||||
if (empty($files)) {
|
|
||||||
return $this->redirectToRoute('app_manga_import');
|
|
||||||
}
|
|
||||||
|
|
||||||
$processedFiles = [];
|
|
||||||
foreach ($files as $fileId => $fileInfo) {
|
|
||||||
$filePath = $fileInfo['path'];
|
|
||||||
$originalFileName = $fileInfo['original_name'];
|
|
||||||
|
|
||||||
$fileExtension = pathinfo($filePath, PATHINFO_EXTENSION);
|
|
||||||
if (strtolower($fileExtension) === 'cbr') {
|
|
||||||
$cbzPath = $this->cbrToCbzConverter->convert($filePath);
|
|
||||||
$filePath = $cbzPath;
|
|
||||||
$originalFileName = pathinfo($originalFileName, PATHINFO_FILENAME) . '.cbz';
|
|
||||||
$files[$fileId]['path'] = $filePath;
|
|
||||||
$files[$fileId]['original_name'] = $originalFileName;
|
|
||||||
}
|
|
||||||
|
|
||||||
$metadata = $this->cbzService->extractMetadata($filePath, $originalFileName);
|
|
||||||
$mangas = $this->mangaRepository->findBySlug($metadata['title']);
|
|
||||||
|
|
||||||
$mangaOptions = [];
|
|
||||||
foreach ($mangas as $manga) {
|
|
||||||
$mangaOptions[] = [
|
|
||||||
'slug' => $manga->getSlug(),
|
|
||||||
'title' => $manga->getTitle(),
|
|
||||||
'author' => $manga->getAuthor(),
|
|
||||||
'publicationYear' => $manga->getPublicationYear(),
|
|
||||||
'genres' => $manga->getGenres(),
|
|
||||||
'description' => $manga->getDescription()
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$processedFiles[] = [
|
|
||||||
'id' => $fileId,
|
|
||||||
'originalFileName' => $originalFileName,
|
|
||||||
'fileSize' => $this->formatBytes(filesize($filePath)),
|
|
||||||
'metadata' => $metadata,
|
|
||||||
'mangaOptions' => $mangaOptions
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$session->set('import_files', $files);
|
|
||||||
|
|
||||||
return $this->render('import/match.html.twig', [
|
|
||||||
'files' => $processedFiles
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function formatBytes($bytes, $precision = 2)
|
|
||||||
{
|
|
||||||
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
||||||
$bytes = max($bytes, 0);
|
|
||||||
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
|
|
||||||
$pow = min($pow, count($units) - 1);
|
|
||||||
$bytes /= (1 << (10 * $pow));
|
|
||||||
return round($bytes, $precision) . ' ' . $units[$pow];
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Route('/import/confirm', name: 'import_confirm', methods: ['POST'])]
|
|
||||||
public function confirm(Request $request, SessionInterface $session): Response
|
|
||||||
{
|
|
||||||
$files = $session->get('import_files', []);
|
|
||||||
$selectedFiles = $request->request->all('selected');
|
|
||||||
$mangaSlugs = $request->request->all('manga_slug');
|
|
||||||
$volumes = $request->request->all('volume');
|
|
||||||
$chapters = $request->request->all('chapter');
|
|
||||||
|
|
||||||
$importedFiles = [];
|
|
||||||
$errors = [];
|
|
||||||
|
|
||||||
foreach ($selectedFiles as $fileId) {
|
|
||||||
if (!isset($files[$fileId])) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$file = $files[$fileId];
|
|
||||||
$mangaSlug = $mangaSlugs[$fileId] ?? null;
|
|
||||||
$volume = $volumes[$fileId] ?? null;
|
|
||||||
$chapter = $chapters[$fileId] ?? null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
$manga = $this->mangaRepository->findOneBy(['slug' => $mangaSlug]);
|
|
||||||
if (!$manga) {
|
|
||||||
throw new \Exception('Manga non trouvé.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!is_null($chapter)) {
|
|
||||||
$chapter = $manga->getChapterByNumber($chapter);
|
|
||||||
if (!$chapter) {
|
|
||||||
throw new \Exception('Chapitre non trouvé.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$importedFiles[] = $file['original_name'];
|
|
||||||
$this->mangaImportService->importFile($manga, $volume, $chapter, $file['path']);
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
$errors[] = "Erreur lors de l'import de {$file['original_name']} : " . $e->getMessage();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nettoyer les fichiers temporaires non importés
|
|
||||||
foreach ($files as $file) {
|
|
||||||
$this->fileSystemManager->deleteFile($file['path']);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nettoyer la session
|
|
||||||
$session->remove('import_files');
|
|
||||||
|
|
||||||
// Préparer le message de notification
|
|
||||||
if (!empty($importedFiles)) {
|
|
||||||
$successMessage = 'Fichiers importés avec succès : ' . implode(', ', $importedFiles);
|
|
||||||
$this->notificationService->sendUpdate([
|
|
||||||
'status' => 'success',
|
|
||||||
'message' => $successMessage
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!empty($errors)) {
|
|
||||||
$errorMessage = implode("\n", $errors);
|
|
||||||
$this->notificationService->sendUpdate([
|
|
||||||
'status' => 'error',
|
|
||||||
'message' => $errorMessage
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->redirectToRoute('app_manga');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,475 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Controller;
|
|
||||||
|
|
||||||
use App\Entity\Chapter;
|
|
||||||
use App\Entity\Manga;
|
|
||||||
use App\Form\MangaEditType;
|
|
||||||
use App\Manager\FileSystemManager;
|
|
||||||
use App\Manager\Toolbar\Factory\ToolbarFactory;
|
|
||||||
use App\Message\DownloadChapter;
|
|
||||||
use App\Message\RefreshMetadata;
|
|
||||||
use App\Repository\ChapterRepository;
|
|
||||||
use App\Repository\ContentSourceRepository;
|
|
||||||
use App\Repository\MangaRepository;
|
|
||||||
use App\Service\CbzService;
|
|
||||||
use App\Service\MangadexProvider;
|
|
||||||
use App\Service\NotificationService;
|
|
||||||
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
|
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
|
||||||
use Doctrine\ORM\NonUniqueResultException;
|
|
||||||
use GuzzleHttp\Client;
|
|
||||||
use GuzzleHttp\Exception\GuzzleException;
|
|
||||||
use Intervention\Image\Drivers\Gd\Driver;
|
|
||||||
use Intervention\Image\ImageManager;
|
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
|
||||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
|
||||||
use Symfony\Component\HttpFoundation\File\Exception\FileException;
|
|
||||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
|
||||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|
||||||
use Symfony\Component\Messenger\MessageBusInterface;
|
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
|
||||||
use Symfony\Component\Serializer\SerializerInterface;
|
|
||||||
use Symfony\Component\String\Slugger\SluggerInterface;
|
|
||||||
|
|
||||||
class MangaController extends AbstractController
|
|
||||||
{
|
|
||||||
private ImageManager $imageManager;
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
private readonly FileSystemManager $fileSystemManager,
|
|
||||||
private readonly MangaRepository $mangaRepository,
|
|
||||||
private readonly ChapterRepository $chapterRepository,
|
|
||||||
private readonly MessageBusInterface $bus,
|
|
||||||
private readonly CbzService $cbzService,
|
|
||||||
private readonly ToolbarFactory $toolbarFactory,
|
|
||||||
private readonly MangadexProvider $mangadexProvider,
|
|
||||||
private readonly EntityManagerInterface $entityManager,
|
|
||||||
private readonly NotificationService $notificationService,
|
|
||||||
private readonly ContentSourceRepository $contentSourceRepository
|
|
||||||
) {
|
|
||||||
$this->imageManager = new ImageManager(new Driver());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Route('/legacy', name: 'app_legacy')]
|
|
||||||
public function index(Request $request): Response
|
|
||||||
{
|
|
||||||
$sort = $request->query->get('sort', 'title');
|
|
||||||
$order = $request->query->get('order', 'asc');
|
|
||||||
$status = $request->query->get('status', 'all');
|
|
||||||
$view = $request->query->get('view', 'poster');
|
|
||||||
|
|
||||||
$mangas = $this->mangaRepository->findAllSortedAndFiltered($sort, $order, $status);
|
|
||||||
|
|
||||||
return $this->render('manga/index.html.twig', [
|
|
||||||
'mangas' => $mangas,
|
|
||||||
'toolbar' => $this->toolbarFactory->createToolbar('manga_list')->getGroups(),
|
|
||||||
'currentStatus' => $status,
|
|
||||||
'currentView' => $view,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Route('/manga/chapters/{mangaSlug}', name: 'app_manga_show')]
|
|
||||||
public function showChapters(string $mangaSlug, Request $request): Response
|
|
||||||
{
|
|
||||||
// $manga = $this->mangaRepository->findOneWithChapterBy(['slug' => $mangaSlug]);
|
|
||||||
$manga = $this->mangaRepository->findOneBy(['slug' => $mangaSlug]);
|
|
||||||
|
|
||||||
if (!$manga) {
|
|
||||||
throw new NotFoundHttpException("Le manga demandé n'existe pas.");
|
|
||||||
}
|
|
||||||
|
|
||||||
$form = $this->createForm(MangaEditType::class, $manga);
|
|
||||||
$contentSources = $this->contentSourceRepository->findAll();
|
|
||||||
|
|
||||||
return $this->render('manga/show_chapters.html.twig', [
|
|
||||||
'manga' => $manga,
|
|
||||||
'toolbar' => $this->toolbarFactory->createToolbar('chapter_list', ['mangaId' => $manga->getId(), 'isMonitored' => (int)$manga->isMonitored()])->getGroups(),
|
|
||||||
'form' => $form->createView(),
|
|
||||||
'contentSources' => $contentSources,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Route('/manga/delete/{id}', name: 'app_manga_delete', methods: ['DELETE'])]
|
|
||||||
public function deleteManga(Manga $manga): JsonResponse
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
foreach ($manga->getChapters() as $chapter) {
|
|
||||||
file_exists($chapter->getCbzPath()) ?? unlink($chapter->getCbzPath());
|
|
||||||
$this->entityManager->remove($chapter);
|
|
||||||
}
|
|
||||||
$this->entityManager->remove($manga);
|
|
||||||
$this->entityManager->flush();
|
|
||||||
|
|
||||||
return new JsonResponse(['success' => true]);
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
return new JsonResponse(['success' => false, 'error' => 'Unable to delete manga.'], 500);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Route('/manga/{id}/edit', name: 'app_manga_edit', methods: ['POST'])]
|
|
||||||
public function edit(Request $request, Manga $manga, EntityManagerInterface $entityManager): JsonResponse|Response
|
|
||||||
{
|
|
||||||
$form = $this->createForm(MangaEditType::class, $manga);
|
|
||||||
$form->handleRequest($request);
|
|
||||||
|
|
||||||
if ($form->isSubmitted() && $form->isValid()) {
|
|
||||||
$entityManager->flush();
|
|
||||||
|
|
||||||
return $this->redirectToRoute('app_manga_show', ['mangaSlug' => $manga->getSlug()]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$errors = [];
|
|
||||||
foreach ($form->getErrors(true) as $error) {
|
|
||||||
$errors[] = $error->getMessage();
|
|
||||||
}
|
|
||||||
|
|
||||||
return new JsonResponse(['errors' => $errors], 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Route('/manga/{id}/preferred-sources', name: 'manga_preferred_sources', methods: ['POST'])]
|
|
||||||
public function updatePreferredSources(
|
|
||||||
Request $request,
|
|
||||||
Manga $manga,
|
|
||||||
ContentSourceRepository $contentSourceRepository
|
|
||||||
): JsonResponse {
|
|
||||||
$data = json_decode($request->getContent(), true);
|
|
||||||
$preferredSourceIds = $data['preferredSources'] ?? [];
|
|
||||||
|
|
||||||
$preferredSources = $contentSourceRepository->findBy(['id' => $preferredSourceIds]);
|
|
||||||
|
|
||||||
// This will maintain the order of the sources as they were sent in the request
|
|
||||||
$orderedPreferredSources = array_map(
|
|
||||||
fn ($id) => current(array_filter($preferredSources, fn ($s) => $s->getId() == $id)),
|
|
||||||
$preferredSourceIds
|
|
||||||
);
|
|
||||||
|
|
||||||
$manga->setPreferredSources(array_filter($orderedPreferredSources));
|
|
||||||
$this->entityManager->flush();
|
|
||||||
|
|
||||||
return new JsonResponse(['success' => true]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function _chaptersByManga(int $id): Response
|
|
||||||
{
|
|
||||||
$manga = $this->mangaRepository->find($id);
|
|
||||||
$chaptersByVolume = [];
|
|
||||||
foreach ($manga->getChapters() as $chapter) {
|
|
||||||
$volume = $chapter->getVolume() ?? 'Not Found';
|
|
||||||
$chaptersByVolume[$volume][] = $chapter;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($chaptersByVolume as $volume => &$chapters) {
|
|
||||||
usort($chapters, function ($a, $b) {
|
|
||||||
return $b->getNumber() <=> $a->getNumber();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
unset($chapters);
|
|
||||||
|
|
||||||
uksort($chaptersByVolume, function ($a, $b) {
|
|
||||||
if ($a == 0) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
if ($b == 0) {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
return $b <=> $a;
|
|
||||||
});
|
|
||||||
|
|
||||||
return $this->render('manga/_chapter_list.html.twig', [
|
|
||||||
'manga' => $manga,
|
|
||||||
'chapters_by_volume' => $chaptersByVolume
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Route('/delete_cbz/{id}', name: 'app_delete_cbz')]
|
|
||||||
public function deleteChapterCbz(Chapter $chapter): JsonResponse
|
|
||||||
{
|
|
||||||
$cbzPath = $chapter->getCbzPath();
|
|
||||||
if (!$cbzPath) {
|
|
||||||
return new JsonResponse(['error' => 'No CBZ path for this chapter.'], 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
file_exists($cbzPath) ?? unlink($cbzPath);
|
|
||||||
|
|
||||||
$chapter->setCbzPath(null);
|
|
||||||
$this->entityManager->persist($chapter);
|
|
||||||
$this->entityManager->flush();
|
|
||||||
|
|
||||||
return new JsonResponse(['success' => 'CBZ file deleted.'], 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Route('/chapter/{id}/edit', name: 'app_chapter_edit', methods: ['POST'])]
|
|
||||||
public function editChapter(Request $request, Chapter $chapter): JsonResponse
|
|
||||||
{
|
|
||||||
$data = json_decode($request->getContent(), true);
|
|
||||||
|
|
||||||
$chapter->setNumber($data['number']);
|
|
||||||
$chapter->setTitle($data['title']);
|
|
||||||
|
|
||||||
$this->entityManager->flush();
|
|
||||||
|
|
||||||
return new JsonResponse(['success' => true, 'message' => 'Chapter updated successfully']);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Route('/hide_chapter/{id}', name: 'app_hide_chapter')]
|
|
||||||
public function hideChapter(Chapter $chapter): JsonResponse
|
|
||||||
{
|
|
||||||
$chapter->setVisible(false);
|
|
||||||
$this->entityManager->persist($chapter);
|
|
||||||
$this->entityManager->flush();
|
|
||||||
|
|
||||||
return new JsonResponse(['success' => 'Chapter hidden.'], 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Route('/manga/search/{query}', name: 'app_manga_search')]
|
|
||||||
public function search(string $query = ''): Response
|
|
||||||
{
|
|
||||||
return $this->render('manga/add_new.html.twig', [
|
|
||||||
'query' => $query,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @throws GuzzleException
|
|
||||||
*/
|
|
||||||
#[Route('/addManga', name: 'app_manga_add')]
|
|
||||||
public function addManga(Request $request): Response
|
|
||||||
{
|
|
||||||
$manga = $this->mangaRepository->findOneBy(['slug' => $request->request->get('slug')]);
|
|
||||||
if ($manga) {
|
|
||||||
return $this->redirectToRoute('app_manga_show', ['mangaSlug' => $manga->getSlug()]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$manga = new Manga();
|
|
||||||
$manga->setTitle($request->request->get('title'))
|
|
||||||
->setSlug($request->request->get('slug'))
|
|
||||||
->setDescription($request->request->get('description'))
|
|
||||||
->setStatus($request->request->get('status'))
|
|
||||||
->setGenres(explode(',', $request->request->get('genres')))
|
|
||||||
->setAuthor($request->request->get('author'))
|
|
||||||
->setPublicationYear($request->request->get('publicationYear'))
|
|
||||||
->setRating($request->request->get('rating'))
|
|
||||||
->setExternalId($request->request->get('externalId'))
|
|
||||||
->setMonitored(false);
|
|
||||||
|
|
||||||
// Traitement de l'image
|
|
||||||
$imageUrl = $request->request->get('imageUrl');
|
|
||||||
try {
|
|
||||||
$imageUrls = $this->processAndSaveImage($imageUrl);
|
|
||||||
$manga->setImageUrl($imageUrls['full']);
|
|
||||||
$manga->setThumbnailUrl($imageUrls['thumbnail']);
|
|
||||||
} catch (\Exception|GuzzleException $e) {
|
|
||||||
throw $e;
|
|
||||||
}
|
|
||||||
|
|
||||||
$mergedChapters = $this->mangadexProvider->addAllChaptersToManga($manga);
|
|
||||||
|
|
||||||
if (empty($mergedChapters)) {
|
|
||||||
return $this->redirectToRoute('app_manga_search', ['query' => $manga->getTitle()]);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
foreach ($manga->getChapters() as $chapter) {
|
|
||||||
$this->entityManager->persist($chapter);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->entityManager->persist($manga);
|
|
||||||
$this->entityManager->flush();
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
if ($e instanceof UniqueConstraintViolationException) {
|
|
||||||
return $this->redirectToRoute('app_manga_show', ['mangaSlug' => $manga->getSlug()]);
|
|
||||||
}
|
|
||||||
throw $e;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->redirectToRoute('app_manga_show', ['mangaSlug' => $manga->getSlug()]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @throws GuzzleException
|
|
||||||
*/
|
|
||||||
private function processAndSaveImage(string $imageUrl): array
|
|
||||||
{
|
|
||||||
$client = new Client();
|
|
||||||
$response = $client->get($imageUrl);
|
|
||||||
$tempImage = tmpfile();
|
|
||||||
fwrite($tempImage, $response->getBody()->getContents());
|
|
||||||
$tempImagePath = stream_get_meta_data($tempImage)['uri'];
|
|
||||||
|
|
||||||
// Générer un nom de fichier unique
|
|
||||||
$originalFilename = pathinfo($imageUrl, PATHINFO_FILENAME);
|
|
||||||
$newFilename = $this->fileSystemManager->generateUniqueImageFilename($imageUrl);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Créer et sauvegarder la miniature
|
|
||||||
$thumbnail = $this->imageManager->read($tempImagePath);
|
|
||||||
$thumbnail->cover(300, 440);
|
|
||||||
$thumbnail->save($this->fileSystemManager->getImagePath('thumbnails') . '/' . $newFilename, quality: 85);
|
|
||||||
|
|
||||||
// Sauvegarder l'image en taille réelle
|
|
||||||
$fullImage = $this->imageManager->read($tempImagePath);
|
|
||||||
$fullImage->save($this->fileSystemManager->getImagePath('full') . '/' . $newFilename, quality: 90);
|
|
||||||
|
|
||||||
// Fermer et supprimer le fichier temporaire
|
|
||||||
fclose($tempImage);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'full' => '/images/full/' . $newFilename,
|
|
||||||
'thumbnail' => '/images/thumbnails/' . $newFilename
|
|
||||||
];
|
|
||||||
|
|
||||||
} catch (FileException $e) {
|
|
||||||
// Fermer le fichier temporaire en cas d'erreur
|
|
||||||
fclose($tempImage);
|
|
||||||
throw $e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Route('/searchChapter/{id}', name: 'search_chapter')]
|
|
||||||
public function addChapterMessenger(int $id): JsonResponse
|
|
||||||
{
|
|
||||||
$chapter = $this->chapterRepository->find($id);
|
|
||||||
if (!$chapter) {
|
|
||||||
return new JsonResponse(['error' => 'Chapter Not Found.'], 400);
|
|
||||||
} elseif ($chapter->getCbzPath() !== null) {
|
|
||||||
return new JsonResponse(['error' => 'Chapter already scraped.'], 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->bus->dispatch(new DownloadChapter($id));
|
|
||||||
|
|
||||||
return new JsonResponse(['success' => 'Scrapping started...'], 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Route('/searchVolume/{mangaSlug}/{volume}', name: 'search_volume')]
|
|
||||||
public function searchVolume(string $mangaSlug, int $volume): JsonResponse
|
|
||||||
{
|
|
||||||
$manga = $this->mangaRepository->findOneBy(['slug' => $mangaSlug]);
|
|
||||||
if (!$manga) {
|
|
||||||
return new JsonResponse(['error' => 'Manga Not Found.'], 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
$volumeChapters = $this->chapterRepository->findBy([
|
|
||||||
'manga' => $manga,
|
|
||||||
'volume' => $volume,
|
|
||||||
'visible' => true
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (empty($volumeChapters)) {
|
|
||||||
$this->notificationService->sendUpdate(['status' => 'error', 'message' => 'No chapters found for this volume.']);
|
|
||||||
return new JsonResponse(['error' => 'No chapters found for this volume.'], 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($volumeChapters as $chapter) {
|
|
||||||
if ($chapter->getCbzPath() === null) {
|
|
||||||
$this->bus->dispatch(new DownloadChapter($chapter->getId()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return new JsonResponse(['success' => 'Scrapping started...'], 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Route('/download-cbz/{chapterId}', name: 'download_cbz')]
|
|
||||||
public function downloadChapter(int $chapterId): BinaryFileResponse|JsonResponse
|
|
||||||
{
|
|
||||||
$chapter = $this->chapterRepository->find($chapterId);
|
|
||||||
if (!$chapter) {
|
|
||||||
$this->notificationService->sendUpdate(['status' => 'error', 'message' => 'Chapitre non trouvé.']);
|
|
||||||
return new JsonResponse(['error' => 'Chapitre non trouvé.'], 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
$cbzPath = $chapter->getCbzPath();
|
|
||||||
if (!$cbzPath || !file_exists($cbzPath)) {
|
|
||||||
$this->notificationService->sendUpdate(['status' => 'error', 'message' => 'Le fichier CBZ n\'existe pas.']);
|
|
||||||
return new JsonResponse(['error' => 'Le fichier CBZ n\'existe pas.'], 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
$isFullVolume = $this->isFullVolume($chapter);
|
|
||||||
$fileName = $isFullVolume
|
|
||||||
? $this->cbzService->generateFileName($chapter->getManga(), $chapter->getVolume())
|
|
||||||
: $this->cbzService->generateFileName($chapter->getManga(), null, $chapter->getNumber());
|
|
||||||
|
|
||||||
return $this->cbzService->createBinaryFileResponse($cbzPath, $fileName);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Route('/download-volume/{mangaSlug}/{volume}', name: 'download_volume')]
|
|
||||||
public function downloadVolume(string $mangaSlug, int $volume): BinaryFileResponse|JsonResponse
|
|
||||||
{
|
|
||||||
$manga = $this->mangaRepository->findOneBy(['slug' => $mangaSlug]);
|
|
||||||
|
|
||||||
$volumeChapters = $this->chapterRepository->findBy([
|
|
||||||
'manga' => $manga,
|
|
||||||
'volume' => $volume,
|
|
||||||
'visible' => true
|
|
||||||
], ['number' => 'ASC']);
|
|
||||||
|
|
||||||
if (empty($volumeChapters)) {
|
|
||||||
$this->notificationService->sendUpdate(['status' => 'error', 'message' => 'Aucun chapitre trouvé pour ce volume.']);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$this->cbzService->doAllChaptersHaveCbz($volumeChapters)) {
|
|
||||||
$this->notificationService->sendUpdate(['status' => 'error', 'message' => 'Tous les chapitres du volume ne sont pas scrapés.']);
|
|
||||||
return new JsonResponse(['error' => 'Tous les chapitres du volume ne sont pas scrapés.'], 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
$fileName = $this->cbzService->generateFileName($manga, $volume);
|
|
||||||
|
|
||||||
if ($this->cbzService->areAllChaptersCbzIdentical($volumeChapters)) {
|
|
||||||
return $this->cbzService->createBinaryFileResponse($volumeChapters[0]->getCbzPath(), $fileName);
|
|
||||||
} else {
|
|
||||||
$tempFile = $this->cbzService->createVolumeArchive($volumeChapters);
|
|
||||||
$response = $this->cbzService->createBinaryFileResponse($tempFile, $fileName);
|
|
||||||
$response->deleteFileAfterSend(true);
|
|
||||||
return $response;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Route('/refresh_metadata', name: 'refresh_metadata')]
|
|
||||||
public function refreshMetadata(Request $request): JsonResponse
|
|
||||||
{
|
|
||||||
$mangaId = json_decode($request->getContent(), true)['mangaId'];
|
|
||||||
$manga = $this->mangaRepository->find($mangaId);
|
|
||||||
if (!$manga) {
|
|
||||||
return new JsonResponse(['error' => 'Manga Not Found.'], 400);
|
|
||||||
}
|
|
||||||
$this->bus->dispatch(new RefreshMetadata($mangaId));
|
|
||||||
|
|
||||||
return new JsonResponse(['success' => 'Metadata refresh started...'], 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Route('/toggle_monitored', name: 'toggle_monitored')]
|
|
||||||
public function toogleMonitored(Request $request): JsonResponse
|
|
||||||
{
|
|
||||||
$id = json_decode($request->getContent(), true)['mangaId'];
|
|
||||||
$manga = $this->mangaRepository->find($id);
|
|
||||||
if (!$manga) {
|
|
||||||
return new JsonResponse(['error' => 'Manga Not Found.'], 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
$manga->setMonitored(!$manga->isMonitored());
|
|
||||||
$this->entityManager->persist($manga);
|
|
||||||
$this->entityManager->flush();
|
|
||||||
|
|
||||||
return new JsonResponse(['success' => 'Monitored status updated.', 'isMonitored' => $manga->isMonitored()], 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function isFullVolume(Chapter $chapter): bool
|
|
||||||
{
|
|
||||||
$volumeChapters = $this->chapterRepository->findBy([
|
|
||||||
'manga' => $chapter->getManga(),
|
|
||||||
'volume' => $chapter->getVolume()
|
|
||||||
]);
|
|
||||||
|
|
||||||
$firstChapterPath = $volumeChapters[0]->getCbzPath();
|
|
||||||
foreach ($volumeChapters as $volumeChapter) {
|
|
||||||
if ($volumeChapter->getCbzPath() !== $firstChapterPath) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Controller;
|
|
||||||
|
|
||||||
use App\Repository\MangaRepository;
|
|
||||||
use App\Service\CbzService;
|
|
||||||
use App\Service\NotificationService;
|
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
|
||||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
|
||||||
|
|
||||||
class ReaderController extends AbstractController
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private readonly MangaRepository $mangaRepository,
|
|
||||||
private readonly CbzService $cbzService,
|
|
||||||
private readonly NotificationService $notificationService,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Route('/read/{mangaSlug}/{chapterNumber}', name: 'app_reader')]
|
|
||||||
public function read(string $mangaSlug, float $chapterNumber): Response
|
|
||||||
{
|
|
||||||
$manga = $this->mangaRepository->findOneBy(['slug' => $mangaSlug]);
|
|
||||||
if (!$manga) {
|
|
||||||
throw $this->createNotFoundException("Le manga demandé n'existe pas.");
|
|
||||||
}
|
|
||||||
|
|
||||||
$chapter = $manga->getChapterByNumber($chapterNumber);
|
|
||||||
if (!$chapter) {
|
|
||||||
throw $this->createNotFoundException("Le chapitre demandé n'existe pas.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (is_null($chapter->getCbzPath())) {
|
|
||||||
$this->notificationService->sendUpdate([
|
|
||||||
'status' => 'error',
|
|
||||||
'message' => 'Le chapitre demandé n\'est pas encore disponible.',
|
|
||||||
]);
|
|
||||||
return $this->redirectToRoute('app_manga_show', ['mangaSlug' => $mangaSlug]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$totalPages = $this->cbzService->getPageCount($chapter->getCbzPath());
|
|
||||||
|
|
||||||
return $this->render('reader/index.html.twig', [
|
|
||||||
'manga' => $manga,
|
|
||||||
'chapter' => $chapter,
|
|
||||||
'totalPages' => $totalPages,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Route('/api/read/{mangaSlug}/{chapterNumber}/{pageNumber}', name: 'app_reader_page')]
|
|
||||||
public function getPage(string $mangaSlug, float $chapterNumber, int $pageNumber): Response
|
|
||||||
{
|
|
||||||
$manga = $this->mangaRepository->findOneBy(['slug' => $mangaSlug]);
|
|
||||||
if (!$manga) {
|
|
||||||
throw $this->createNotFoundException("Le manga demandé n'existe pas.");
|
|
||||||
}
|
|
||||||
|
|
||||||
$chapter = $manga->getChapterByNumber($chapterNumber);
|
|
||||||
if (!$chapter) {
|
|
||||||
throw $this->createNotFoundException("Le chapitre demandé n'existe pas.");
|
|
||||||
}
|
|
||||||
|
|
||||||
$pageContent = $this->cbzService->getPageContent($chapter->getCbzPath(), $pageNumber);
|
|
||||||
if (!$pageContent) {
|
|
||||||
throw $this->createNotFoundException("La page demandée n'existe pas.");
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Response(base64_encode($pageContent), 200, ['Content-Type' => 'text/plain']);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Route('/api/chapters/{mangaSlug}', name: 'app_reader_chapters')]
|
|
||||||
public function getChapters(string $mangaSlug): JsonResponse
|
|
||||||
{
|
|
||||||
$manga = $this->mangaRepository->findOneBy(['slug' => $mangaSlug]);
|
|
||||||
if (!$manga) {
|
|
||||||
throw $this->createNotFoundException("Le manga demandé n'existe pas.");
|
|
||||||
}
|
|
||||||
|
|
||||||
$chapters = $manga->getChapters()
|
|
||||||
->filter(fn ($chapter) => $chapter->isVisible() && !is_null($chapter->getCbzPath()))
|
|
||||||
->toArray();
|
|
||||||
|
|
||||||
usort($chapters, fn ($a, $b) => $b->getNumber() <=> $a->getNumber());
|
|
||||||
|
|
||||||
$chapters = array_values(array_map(fn ($chapter) => [
|
|
||||||
'number' => $chapter->getNumber(),
|
|
||||||
'title' => $chapter->getTitle(),
|
|
||||||
], $chapters));
|
|
||||||
|
|
||||||
return $this->json($chapters);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Route('/api/previous-chapter/{mangaSlug}/{currentChapterNumber}', name: 'app_reader_previous_chapter')]
|
|
||||||
public function getPreviousChapter(string $mangaSlug, float $currentChapterNumber): JsonResponse
|
|
||||||
{
|
|
||||||
$manga = $this->mangaRepository->findOneBy(['slug' => $mangaSlug]);
|
|
||||||
if (!$manga) {
|
|
||||||
throw $this->createNotFoundException("Le manga demandé n'existe pas.");
|
|
||||||
}
|
|
||||||
|
|
||||||
$chapters = $manga->getChapters()
|
|
||||||
->filter(fn ($chapter) => $chapter->isVisible() && $chapter->getNumber() < $currentChapterNumber)
|
|
||||||
->toArray();
|
|
||||||
|
|
||||||
usort($chapters, fn ($a, $b) => $b->getNumber() <=> $a->getNumber());
|
|
||||||
|
|
||||||
$previousChapter = reset($chapters) ?: null;
|
|
||||||
|
|
||||||
return $this->json($previousChapter ? [
|
|
||||||
'number' => $previousChapter->getNumber(),
|
|
||||||
'title' => $previousChapter->getTitle(),
|
|
||||||
] : null);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Route('/api/next-chapter/{mangaSlug}/{currentChapterNumber}', name: 'app_reader_next_chapter')]
|
|
||||||
public function getNextChapter(string $mangaSlug, float $currentChapterNumber): JsonResponse
|
|
||||||
{
|
|
||||||
$manga = $this->mangaRepository->findOneBy(['slug' => $mangaSlug]);
|
|
||||||
if (!$manga) {
|
|
||||||
throw $this->createNotFoundException("Le manga demandé n'existe pas.");
|
|
||||||
}
|
|
||||||
|
|
||||||
$nextChapter = $manga->getChapters()
|
|
||||||
->filter(fn ($chapter) => $chapter->isVisible() && $chapter->getNumber() > $currentChapterNumber)
|
|
||||||
->toArray();
|
|
||||||
|
|
||||||
usort($nextChapter, fn ($a, $b) => $a->getNumber() <=> $b->getNumber());
|
|
||||||
|
|
||||||
$nextChapter = reset($nextChapter) ?: null;
|
|
||||||
|
|
||||||
return $this->json($nextChapter ? [
|
|
||||||
'number' => $nextChapter->getNumber(),
|
|
||||||
'title' => $nextChapter->getTitle(),
|
|
||||||
] : null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,6 @@ namespace App\Controller;
|
|||||||
|
|
||||||
use ApiPlatform\Api\IriConverterInterface;
|
use ApiPlatform\Api\IriConverterInterface;
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
use Exception;
|
|
||||||
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\Annotation\Route;
|
||||||
@@ -13,11 +12,11 @@ use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
|||||||
class SecurityController extends AbstractController
|
class SecurityController extends AbstractController
|
||||||
{
|
{
|
||||||
#[Route('/login', name: 'app_login', methods: ['GET', 'POST'])]
|
#[Route('/login', name: 'app_login', methods: ['GET', 'POST'])]
|
||||||
public function login(IriConverterInterface $iriConverter, #[CurrentUser] User $user = null): Response
|
public function login(IriConverterInterface $iriConverter, #[CurrentUser] ?User $user = null): Response
|
||||||
{
|
{
|
||||||
if (!$user) {
|
if (!$user) {
|
||||||
return $this->json([
|
return $this->json([
|
||||||
'error' => 'Invalid credentials'
|
'error' => 'Invalid credentials',
|
||||||
], 401);
|
], 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,11 +26,11 @@ class SecurityController extends AbstractController
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @throws Exception
|
* @throws \Exception
|
||||||
*/
|
*/
|
||||||
#[Route('/logout', name: 'app_logout', methods: ['GET'])]
|
#[Route('/logout', name: 'app_logout', methods: ['GET'])]
|
||||||
public function logout(): void
|
public function logout(): void
|
||||||
{
|
{
|
||||||
throw new Exception('This method can be blank.');
|
throw new \Exception('This method can be blank.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,203 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Controller;
|
|
||||||
|
|
||||||
use App\Entity\ContentSource;
|
|
||||||
use App\Form\AppSettingsType;
|
|
||||||
use App\Form\ContentSourceType;
|
|
||||||
use App\Manager\AppSettingsManager;
|
|
||||||
use App\Manager\Toolbar\Factory\ToolbarFactory;
|
|
||||||
use App\Repository\ContentSourceRepository;
|
|
||||||
use App\Service\NotificationService;
|
|
||||||
use App\Service\Scraper\MangaScraperService;
|
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
|
||||||
use GuzzleHttp\Exception\GuzzleException;
|
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
|
||||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
|
||||||
|
|
||||||
class SettingsController extends AbstractController
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private MangaScraperService $mangaScraperService,
|
|
||||||
private EntityManagerInterface $entityManager,
|
|
||||||
private NotificationService $notificationService,
|
|
||||||
private ContentSourceRepository $contentSourceRepository
|
|
||||||
) {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Route('/settings', name: 'app_settings')]
|
|
||||||
public function index(): Response
|
|
||||||
{
|
|
||||||
return $this->render('settings/index.html.twig', [
|
|
||||||
'controller_name' => 'SettingsController',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Route('/settings/general', name: 'app_settings_general')]
|
|
||||||
public function general(): Response
|
|
||||||
{
|
|
||||||
return $this->render('settings/index.html.twig', [
|
|
||||||
'controller_name' => 'SettingsController',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Route('/settings/folders', name: 'app_settings_folders')]
|
|
||||||
public function folders(Request $request, AppSettingsManager $settingsManager): Response
|
|
||||||
{
|
|
||||||
$currentSettings = $settingsManager->getSettings();
|
|
||||||
|
|
||||||
$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')]
|
|
||||||
public function list(ContentSourceRepository $repository, ToolbarFactory $toolbarFactory): Response
|
|
||||||
{
|
|
||||||
$contentSources = $repository->findAll();
|
|
||||||
|
|
||||||
return $this->render('settings/scrapper_list.html.twig', [
|
|
||||||
'contentSources' => $contentSources,
|
|
||||||
'toolbar' => $toolbarFactory->createToolbar('scraper_list')->getGroups(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Route('/settings/scrappers/{id}', name: 'app_settings_scrappers', defaults: ['id' => null])]
|
|
||||||
public function scrappers(Request $request, ?ContentSource $contentSource): Response
|
|
||||||
{
|
|
||||||
$isNew = $contentSource === null;
|
|
||||||
$contentSource = $contentSource ?? new ContentSource();
|
|
||||||
|
|
||||||
$form = $this->createForm(ContentSourceType::class, $contentSource);
|
|
||||||
$form->handleRequest($request);
|
|
||||||
|
|
||||||
if ($form->isSubmitted() && $form->isValid()) {
|
|
||||||
$this->entityManager->persist($contentSource);
|
|
||||||
$this->entityManager->flush();
|
|
||||||
$this->notificationService->sendUpdate(['status' => 'success', 'message' => ($isNew ? 'New scrapper configuration saved' : 'Scrapper configuration updated') . ' successfully.']);
|
|
||||||
return $this->redirectToRoute('app_settings_scrappers_list');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->render('settings/scrappers.html.twig', [
|
|
||||||
'form' => $form->createView(),
|
|
||||||
'isNew' => $isNew,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @throws GuzzleException
|
|
||||||
*/
|
|
||||||
#[Route('/settings/scrappers_test', name: 'app_settings_scrappers_test', methods: ['POST'])]
|
|
||||||
public function scrapperTest(Request $request): JsonResponse
|
|
||||||
{
|
|
||||||
$contentSource = new ContentSource();
|
|
||||||
$form = $this->createForm(ContentSourceType::class, $contentSource);
|
|
||||||
$form->submit($request->request->all()['content_source']);
|
|
||||||
|
|
||||||
if ($form->isValid()) {
|
|
||||||
$mangaSlug = $request->request->get('mangaSlug');
|
|
||||||
$chapterNumber = $request->request->get('chapterNumber');
|
|
||||||
|
|
||||||
try {
|
|
||||||
$scrapedData = $this->mangaScraperService->testScraping($mangaSlug, $chapterNumber, $contentSource);
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
$this->notificationService->sendUpdate(['status' => 'error', 'message' => $e->getMessage()]);
|
|
||||||
return new JsonResponse([
|
|
||||||
'success' => false,
|
|
||||||
'message' => $e->getMessage(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new JsonResponse([
|
|
||||||
'success' => true,
|
|
||||||
'message' => 'Test successful',
|
|
||||||
'data' => $scrapedData
|
|
||||||
]);
|
|
||||||
} else {
|
|
||||||
return new JsonResponse([
|
|
||||||
'success' => false,
|
|
||||||
'message' => 'Invalid form submission',
|
|
||||||
'errors' => $this->getFormErrors($form)
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getFormErrors($form): array
|
|
||||||
{
|
|
||||||
$errors = [];
|
|
||||||
foreach ($form->getErrors(true) as $error) {
|
|
||||||
$errors[] = $error->getMessage();
|
|
||||||
}
|
|
||||||
return $errors;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Route('/settings/ui', name: 'app_settings_ui')]
|
|
||||||
public function ui(): Response
|
|
||||||
{
|
|
||||||
return $this->render('settings/index.html.twig', [
|
|
||||||
'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']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Controller;
|
|
||||||
|
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
|
||||||
|
|
||||||
class SystemController extends AbstractController
|
|
||||||
{
|
|
||||||
#[Route('/system', name: 'app_system')]
|
|
||||||
public function index(): Response
|
|
||||||
{
|
|
||||||
return $this->render('system/index.html.twig', [
|
|
||||||
'controller_name' => 'SystemController',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Route('/system/status', name: 'app_system_status')]
|
|
||||||
public function status(): Response
|
|
||||||
{
|
|
||||||
return $this->render('system/index.html.twig', [
|
|
||||||
'controller_name' => 'SettingsController',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Route('/system/backup', name: 'app_system_backup')]
|
|
||||||
public function backup(): Response
|
|
||||||
{
|
|
||||||
return $this->render('system/index.html.twig', [
|
|
||||||
'controller_name' => 'SettingsController',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Route('/system/logs', name: 'app_system_logs')]
|
|
||||||
public function logs(): Response
|
|
||||||
{
|
|
||||||
return $this->render('system/index.html.twig', [
|
|
||||||
'controller_name' => 'SettingsController',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Route('/system/updates', name: 'app_system_updates')]
|
|
||||||
public function update(): Response
|
|
||||||
{
|
|
||||||
return $this->render('system/index.html.twig', [
|
|
||||||
'controller_name' => 'SettingsController',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Controller;
|
|
||||||
|
|
||||||
use App\Entity\Chapter;
|
|
||||||
use App\Entity\ContentSource;
|
|
||||||
use App\Entity\Manga;
|
|
||||||
use App\Message\DownloadChapter;
|
|
||||||
use App\Repository\ChapterRepository;
|
|
||||||
use App\Repository\MangaRepository;
|
|
||||||
use App\Service\ActivityService;
|
|
||||||
use App\Service\MangadexProvider;
|
|
||||||
use App\Service\MangaScraperService;
|
|
||||||
use App\Service\MangaUpdatesMetadataProvider;
|
|
||||||
use Doctrine\DBAL\Connection;
|
|
||||||
use GuzzleHttp\Client;
|
|
||||||
use GuzzleHttp\Exception\GuzzleException;
|
|
||||||
use Intervention\Image\Drivers\Gd\Driver;
|
|
||||||
use Intervention\Image\ImageManager;
|
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
|
||||||
use Symfony\Component\HttpFoundation\File\Exception\FileException;
|
|
||||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
|
||||||
use Symfony\Component\Messenger\Envelope;
|
|
||||||
use Symfony\Component\Messenger\MessageBusInterface;
|
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
|
||||||
use Symfony\Component\Serializer\SerializerInterface;
|
|
||||||
use Symfony\Component\String\Slugger\SluggerInterface;
|
|
||||||
|
|
||||||
class TestController extends AbstractController
|
|
||||||
{
|
|
||||||
private ImageManager $imageManager;
|
|
||||||
public function __construct(
|
|
||||||
private string $projectDir,
|
|
||||||
private SluggerInterface $slugger,
|
|
||||||
private MangaRepository $mangaRepository
|
|
||||||
) {
|
|
||||||
$this->imageManager = new ImageManager(new Driver());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Route('/test', name: 'test')]
|
|
||||||
public function test(): Response
|
|
||||||
{
|
|
||||||
$mangas = $this->mangaRepository->findAll();
|
|
||||||
|
|
||||||
$changed = 0;
|
|
||||||
foreach ($mangas as $manga) {
|
|
||||||
//si getImageUrl() retourne un lien sous la forme d'une URL (https ou http)
|
|
||||||
if ($manga->getImageUrl()) {
|
|
||||||
$imageUrls = $this->processAndSaveImage($manga->getImageUrl());
|
|
||||||
$manga->setThumbnailUrl($imageUrls['thumbnail']);
|
|
||||||
$this->mangaRepository->save($manga, true);
|
|
||||||
$changed++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
return new JsonResponse(['changed' => $changed]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @throws GuzzleException
|
|
||||||
*/
|
|
||||||
private function processAndSaveImage(string $imageUrl): array
|
|
||||||
{
|
|
||||||
$image = file_get_contents($this->projectDir . '/public' .$imageUrl);
|
|
||||||
$tempImage = tmpfile();
|
|
||||||
fwrite($tempImage, $image);
|
|
||||||
$tempImagePath = stream_get_meta_data($tempImage)['uri'];
|
|
||||||
|
|
||||||
// Générer un nom de fichier unique
|
|
||||||
$originalFilename = pathinfo($imageUrl, PATHINFO_FILENAME);
|
|
||||||
$safeFilename = $this->slugger->slug($originalFilename);
|
|
||||||
$newFilename = $safeFilename . '-' . uniqid() . '.' . 'jpg';
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Créer et sauvegarder la miniature
|
|
||||||
$thumbnail = $this->imageManager->read($tempImagePath);
|
|
||||||
$thumbnail->cover(300, 440);
|
|
||||||
$thumbnail->save($this->projectDir . '/public/images/thumbnails/' . $newFilename, quality: 85);
|
|
||||||
|
|
||||||
// Sauvegarder l'image en taille réelle
|
|
||||||
// $fullImage = $this->imageManager->read($tempImagePath);
|
|
||||||
// $fullImage->save($this->projectDir . '/public/images/full/' . $newFilename, quality: 90);
|
|
||||||
|
|
||||||
// Fermer et supprimer le fichier temporaire
|
|
||||||
fclose($tempImage);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'full' => '/images/full/' . $newFilename,
|
|
||||||
'thumbnail' => '/images/thumbnails/' . $newFilename
|
|
||||||
];
|
|
||||||
|
|
||||||
} catch (FileException $e) {
|
|
||||||
// Fermer le fichier temporaire en cas d'erreur
|
|
||||||
fclose($tempImage);
|
|
||||||
throw $e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\DataFixtures;
|
|
||||||
|
|
||||||
use App\Factory\ApiTokenFactory;
|
|
||||||
use App\Factory\ChapterFactory;
|
|
||||||
use App\Factory\MangaFactory;
|
|
||||||
use App\Factory\PageFactory;
|
|
||||||
use App\Factory\UserFactory;
|
|
||||||
use Doctrine\Bundle\FixturesBundle\Fixture;
|
|
||||||
use Doctrine\Persistence\ObjectManager;
|
|
||||||
|
|
||||||
class AppFixtures extends Fixture
|
|
||||||
{
|
|
||||||
public function load(ObjectManager $manager): void
|
|
||||||
{
|
|
||||||
UserFactory::createMany(20);
|
|
||||||
ApiTokenFactory::createMany(60, function () {
|
|
||||||
return [
|
|
||||||
'ownedBy' => UserFactory::random()
|
|
||||||
];
|
|
||||||
});
|
|
||||||
|
|
||||||
$mangas = MangaFactory::createMany(25);
|
|
||||||
|
|
||||||
foreach ($mangas as $manga) {
|
|
||||||
|
|
||||||
for ($i = 1; $i <= 5; $i++) {
|
|
||||||
$manga->addChapter(ChapterFactory::createOne([
|
|
||||||
'manga' => $manga,
|
|
||||||
'number' => $i
|
|
||||||
])->object());
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($manga->getChapters() as $chapter) {
|
|
||||||
for ($i = 1; $i <= 5; $i++) {
|
|
||||||
$chapter->addPagesLink(PageFactory::createOne([
|
|
||||||
'chapter' => $chapter,
|
|
||||||
'number' => $i
|
|
||||||
])->object());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,7 @@ final readonly class ConvertFileCommand
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
public string $filePath,
|
public string $filePath,
|
||||||
public string $originalFilename,
|
public string $originalFilename,
|
||||||
public int $fileSize
|
public int $fileSize,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ final readonly class ConvertFileCommandHandler
|
|||||||
private const MAX_FILE_SIZE = 150 * 1024 * 1024; // 150MB
|
private const MAX_FILE_SIZE = 150 * 1024 * 1024; // 150MB
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private ConversionServiceInterface $conversionService
|
private ConversionServiceInterface $conversionService,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ final readonly class ConversionResponse
|
|||||||
public string $convertedFilePath,
|
public string $convertedFilePath,
|
||||||
public string $outputFilename,
|
public string $outputFilename,
|
||||||
public int $originalFileSize,
|
public int $originalFileSize,
|
||||||
public int $convertedFileSize
|
public int $convertedFileSize,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Domain\Conversion\Domain\Exception;
|
namespace App\Domain\Conversion\Domain\Exception;
|
||||||
|
|
||||||
use RuntimeException;
|
class ConversionException extends \RuntimeException
|
||||||
|
|
||||||
class ConversionException extends RuntimeException
|
|
||||||
{
|
{
|
||||||
public static function fileNotFound(string $filePath): self
|
public static function fileNotFound(string $filePath): self
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ final readonly class ConversionRequest
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private string $filePath,
|
private string $filePath,
|
||||||
private string $originalFilename,
|
private string $originalFilename,
|
||||||
private int $fileSize
|
private int $fileSize,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,6 +29,7 @@ final readonly class ConversionRequest
|
|||||||
public function getOutputFilename(): string
|
public function getOutputFilename(): string
|
||||||
{
|
{
|
||||||
$pathInfo = pathinfo($this->originalFilename, PATHINFO_FILENAME);
|
$pathInfo = pathinfo($this->originalFilename, PATHINFO_FILENAME);
|
||||||
return $pathInfo . '.cbz';
|
|
||||||
|
return $pathInfo.'.cbz';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ final readonly class ConversionResult
|
|||||||
private string $convertedFilePath,
|
private string $convertedFilePath,
|
||||||
private string $outputFilename,
|
private string $outputFilename,
|
||||||
private int $originalFileSize,
|
private int $originalFileSize,
|
||||||
private int $convertedFileSize
|
private int $convertedFileSize,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,18 +5,16 @@ namespace App\Domain\Conversion\Infrastructure\ApiPlatform\Controller;
|
|||||||
use App\Domain\Conversion\Application\Command\ConvertFileCommand;
|
use App\Domain\Conversion\Application\Command\ConvertFileCommand;
|
||||||
use App\Domain\Conversion\Application\CommandHandler\ConvertFileCommandHandler;
|
use App\Domain\Conversion\Application\CommandHandler\ConvertFileCommandHandler;
|
||||||
use App\Domain\Conversion\Domain\Exception\ConversionException;
|
use App\Domain\Conversion\Domain\Exception\ConversionException;
|
||||||
use App\Domain\Conversion\Infrastructure\ApiPlatform\Resource\ConvertFileResource;
|
|
||||||
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\HttpKernel\Attribute\AsController;
|
use Symfony\Component\HttpKernel\Attribute\AsController;
|
||||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
|
||||||
|
|
||||||
#[AsController]
|
#[AsController]
|
||||||
final class ConvertFileController extends AbstractController
|
final class ConvertFileController extends AbstractController
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ConvertFileCommandHandler $commandHandler
|
private readonly ConvertFileCommandHandler $commandHandler,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,7 +23,7 @@ final class ConvertFileController extends AbstractController
|
|||||||
$uploadedFile = $request->files->get('file');
|
$uploadedFile = $request->files->get('file');
|
||||||
if (!$uploadedFile) {
|
if (!$uploadedFile) {
|
||||||
return $this->json([
|
return $this->json([
|
||||||
['propertyPath' => 'file', 'message' => 'Please upload a file']
|
['propertyPath' => 'file', 'message' => 'Please upload a file'],
|
||||||
], 422);
|
], 422);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,7 +56,6 @@ final class ConvertFileController extends AbstractController
|
|||||||
'Content-Disposition' => sprintf('attachment; filename=%s', $response->outputFilename),
|
'Content-Disposition' => sprintf('attachment; filename=%s', $response->outputFilename),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
} catch (ConversionException $e) {
|
} catch (ConversionException $e) {
|
||||||
return $this->json(['error' => $e->getMessage()], 400);
|
return $this->json(['error' => $e->getMessage()], 400);
|
||||||
}
|
}
|
||||||
@@ -72,8 +69,9 @@ final class ConvertFileController extends AbstractController
|
|||||||
if (!$uploadedFile->isValid()) {
|
if (!$uploadedFile->isValid()) {
|
||||||
$errors[] = [
|
$errors[] = [
|
||||||
'propertyPath' => 'file',
|
'propertyPath' => 'file',
|
||||||
'message' => 'The uploaded file is not valid: ' . $uploadedFile->getErrorMessage()
|
'message' => 'The uploaded file is not valid: '.$uploadedFile->getErrorMessage(),
|
||||||
];
|
];
|
||||||
|
|
||||||
return $errors;
|
return $errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,7 +80,7 @@ final class ConvertFileController extends AbstractController
|
|||||||
if ($uploadedFile->getSize() > $maxSize) {
|
if ($uploadedFile->getSize() > $maxSize) {
|
||||||
$errors[] = [
|
$errors[] = [
|
||||||
'propertyPath' => 'file',
|
'propertyPath' => 'file',
|
||||||
'message' => 'The uploaded file is too large. Allowed size is 150MB.'
|
'message' => 'The uploaded file is too large. Allowed size is 150MB.',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,7 +91,7 @@ final class ConvertFileController extends AbstractController
|
|||||||
if (!in_array($extension, $allowedExtensions)) {
|
if (!in_array($extension, $allowedExtensions)) {
|
||||||
$errors[] = [
|
$errors[] = [
|
||||||
'propertyPath' => 'file',
|
'propertyPath' => 'file',
|
||||||
'message' => 'Please upload a valid CBR or CBZ file'
|
'message' => 'Please upload a valid CBR or CBZ file',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ namespace App\Domain\Conversion\Infrastructure\ApiPlatform\Resource;
|
|||||||
|
|
||||||
use ApiPlatform\Metadata\ApiResource;
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
use ApiPlatform\Metadata\Post;
|
use ApiPlatform\Metadata\Post;
|
||||||
use ApiPlatform\OpenApi\Model;
|
use ApiPlatform\OpenApi\Model\Operation;
|
||||||
|
use ApiPlatform\OpenApi\Model\RequestBody;
|
||||||
use App\Domain\Conversion\Infrastructure\ApiPlatform\Controller\ConvertFileController;
|
use App\Domain\Conversion\Infrastructure\ApiPlatform\Controller\ConvertFileController;
|
||||||
use Symfony\Component\HttpFoundation\File\File;
|
use Symfony\Component\HttpFoundation\File\File;
|
||||||
use Symfony\Component\Validator\Constraints as Assert;
|
|
||||||
|
|
||||||
#[ApiResource(
|
#[ApiResource(
|
||||||
shortName: 'Conversion',
|
shortName: 'Conversion',
|
||||||
@@ -16,11 +16,11 @@ use Symfony\Component\Validator\Constraints as Assert;
|
|||||||
uriTemplate: '/conversions/convert',
|
uriTemplate: '/conversions/convert',
|
||||||
controller: ConvertFileController::class,
|
controller: ConvertFileController::class,
|
||||||
deserialize: false,
|
deserialize: false,
|
||||||
openapiContext: [
|
openapi: new Operation(
|
||||||
'summary' => 'Convert comic book file to CBZ',
|
summary: 'Convert comic book file to CBZ',
|
||||||
'description' => 'Converts a CBR or CBZ file to CBZ format and returns the converted file for download',
|
description: 'Converts a CBR or CBZ file to CBZ format and returns the converted file for download',
|
||||||
'requestBody' => [
|
requestBody: new RequestBody(
|
||||||
'content' => [
|
content: new \ArrayObject([
|
||||||
'multipart/form-data' => [
|
'multipart/form-data' => [
|
||||||
'schema' => [
|
'schema' => [
|
||||||
'type' => 'object',
|
'type' => 'object',
|
||||||
@@ -29,28 +29,28 @@ use Symfony\Component\Validator\Constraints as Assert;
|
|||||||
'file' => [
|
'file' => [
|
||||||
'type' => 'string',
|
'type' => 'string',
|
||||||
'format' => 'binary',
|
'format' => 'binary',
|
||||||
'description' => 'Comic book file to convert (CBR, CBZ, max 150MB)'
|
'description' => 'Comic book file to convert (CBR, CBZ, max 150MB)',
|
||||||
]
|
],
|
||||||
]
|
],
|
||||||
]
|
],
|
||||||
]
|
],
|
||||||
]
|
])
|
||||||
],
|
),
|
||||||
'responses' => [
|
responses: [
|
||||||
'200' => [
|
'200' => [
|
||||||
'description' => 'File converted successfully',
|
'description' => 'File converted successfully',
|
||||||
'content' => [
|
'content' => [
|
||||||
'application/x-cbz' => [
|
'application/x-cbz' => [
|
||||||
'schema' => [
|
'schema' => [
|
||||||
'type' => 'string',
|
'type' => 'string',
|
||||||
'format' => 'binary'
|
'format' => 'binary',
|
||||||
]
|
],
|
||||||
]
|
],
|
||||||
]
|
],
|
||||||
]
|
],
|
||||||
]
|
]
|
||||||
]
|
)
|
||||||
)
|
),
|
||||||
]
|
]
|
||||||
)]
|
)]
|
||||||
class ConvertFileResource
|
class ConvertFileResource
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ final class ConversionService implements ConversionServiceInterface
|
|||||||
|
|
||||||
public function __construct(string $projectDir)
|
public function __construct(string $projectDir)
|
||||||
{
|
{
|
||||||
$this->tempDir = $projectDir . '/public/tmp';
|
$this->tempDir = $projectDir.'/public/tmp';
|
||||||
$this->filesystem = new Filesystem();
|
$this->filesystem = new Filesystem();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,10 +40,10 @@ final class ConversionService implements ConversionServiceInterface
|
|||||||
|
|
||||||
private function convertCbrToCbz(string $cbrPath): string
|
private function convertCbrToCbz(string $cbrPath): string
|
||||||
{
|
{
|
||||||
$tempDir = $this->tempDir . '/' . uniqid('cbr_conversion_');
|
$tempDir = $this->tempDir.'/'.uniqid('cbr_conversion_');
|
||||||
$this->filesystem->mkdir($tempDir);
|
$this->filesystem->mkdir($tempDir);
|
||||||
|
|
||||||
$extractDir = $tempDir . '/extract';
|
$extractDir = $tempDir.'/extract';
|
||||||
$this->filesystem->mkdir($extractDir);
|
$this->filesystem->mkdir($extractDir);
|
||||||
|
|
||||||
// Essayer d'extraire avec unrar-free
|
// Essayer d'extraire avec unrar-free
|
||||||
@@ -56,16 +56,16 @@ final class ConversionService implements ConversionServiceInterface
|
|||||||
$process->run();
|
$process->run();
|
||||||
|
|
||||||
if (!$process->isSuccessful()) {
|
if (!$process->isSuccessful()) {
|
||||||
throw new \RuntimeException("Extraction failed: " . $process->getErrorOutput());
|
throw new \RuntimeException('Extraction failed: '.$process->getErrorOutput());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Créer le CBZ
|
// Créer le CBZ
|
||||||
$cbzFileName = pathinfo($cbrPath, PATHINFO_FILENAME) . '.cbz';
|
$cbzFileName = pathinfo($cbrPath, PATHINFO_FILENAME).'.cbz';
|
||||||
$cbzPath = $this->tempDir . '/' . $cbzFileName;
|
$cbzPath = $this->tempDir.'/'.$cbzFileName;
|
||||||
$zip = new \ZipArchive();
|
$zip = new \ZipArchive();
|
||||||
if ($zip->open($cbzPath, \ZipArchive::CREATE) !== true) {
|
if (true !== $zip->open($cbzPath, \ZipArchive::CREATE)) {
|
||||||
throw new \RuntimeException("Cannot create ZIP file");
|
throw new \RuntimeException('Cannot create ZIP file');
|
||||||
}
|
}
|
||||||
|
|
||||||
$files = new \RecursiveIteratorIterator(
|
$files = new \RecursiveIteratorIterator(
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ readonly class ChapterEditData
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
public string $id,
|
public string $id,
|
||||||
public ?string $title = null,
|
public ?string $title = null,
|
||||||
public ?int $volume = null
|
public ?int $volume = null,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,10 @@
|
|||||||
|
|
||||||
namespace App\Domain\Manga\Application\Command;
|
namespace App\Domain\Manga\Application\Command;
|
||||||
|
|
||||||
use DateTimeImmutable;
|
|
||||||
|
|
||||||
readonly class CheckMonitoredMangas
|
readonly class CheckMonitoredMangas
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public ?DateTimeImmutable $since = null
|
public ?\DateTimeImmutable $since = null,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ readonly class CreateManga
|
|||||||
public string $status,
|
public string $status,
|
||||||
public ?string $externalId,
|
public ?string $externalId,
|
||||||
public ?string $imageUrl,
|
public ?string $imageUrl,
|
||||||
public ?float $rating
|
public ?float $rating,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ namespace App\Domain\Manga\Application\Command;
|
|||||||
readonly class CreateMangaFromMangadex
|
readonly class CreateMangaFromMangadex
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public string $externalId
|
public string $externalId,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use App\Domain\Shared\Domain\Contract\CommandInterface;
|
|||||||
readonly class DeleteCbz implements CommandInterface
|
readonly class DeleteCbz implements CommandInterface
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public string $chapterId
|
public string $chapterId,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use App\Domain\Shared\Domain\Contract\CommandInterface;
|
|||||||
readonly class DeleteChapter implements CommandInterface
|
readonly class DeleteChapter implements CommandInterface
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public string $chapterId
|
public string $chapterId,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use App\Domain\Shared\Domain\Contract\CommandInterface;
|
|||||||
readonly class DeleteManga implements CommandInterface
|
readonly class DeleteManga implements CommandInterface
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public string $mangaId
|
public string $mangaId,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ readonly class EditManga
|
|||||||
public ?array $genres = null,
|
public ?array $genres = null,
|
||||||
public ?string $status = null,
|
public ?string $status = null,
|
||||||
public ?float $rating = null,
|
public ?float $rating = null,
|
||||||
public ?array $alternativeSlugs = null
|
public ?array $alternativeSlugs = null,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ readonly class EditMultipleChapters
|
|||||||
* @param array<ChapterEditData> $chapters
|
* @param array<ChapterEditData> $chapters
|
||||||
*/
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public array $chapters
|
public array $chapters,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use App\Domain\Manga\Domain\Model\ValueObject\MangaId;
|
|||||||
readonly class FetchMangaChapters
|
readonly class FetchMangaChapters
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public MangaId $mangaId
|
public MangaId $mangaId,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ readonly class ImportChapter
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
public string $mangaId,
|
public string $mangaId,
|
||||||
public float $chapterNumber,
|
public float $chapterNumber,
|
||||||
public string $fileBinary
|
public string $fileBinary,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ readonly class ImportVolume
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
public string $mangaId,
|
public string $mangaId,
|
||||||
public int $volumeNumber,
|
public int $volumeNumber,
|
||||||
public string $fileBinary
|
public string $fileBinary,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use App\Domain\Manga\Domain\Model\ValueObject\MangaId;
|
|||||||
readonly class RefreshMangaChapters
|
readonly class RefreshMangaChapters
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public MangaId $mangaId
|
public MangaId $mangaId,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ readonly class ToggleMangaMonitoring
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public MangaId $mangaId,
|
public MangaId $mangaId,
|
||||||
public bool $enabled
|
public bool $enabled,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,14 +6,13 @@ use App\Domain\Manga\Application\Command\CheckMonitoredMangas;
|
|||||||
use App\Domain\Manga\Application\Command\RefreshMangaChapters;
|
use App\Domain\Manga\Application\Command\RefreshMangaChapters;
|
||||||
use App\Domain\Manga\Application\Query\MonitoringCriteria;
|
use App\Domain\Manga\Application\Query\MonitoringCriteria;
|
||||||
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
|
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
|
||||||
use DateTimeImmutable;
|
|
||||||
use Symfony\Component\Messenger\MessageBusInterface;
|
use Symfony\Component\Messenger\MessageBusInterface;
|
||||||
|
|
||||||
readonly class CheckMonitoredMangasHandler
|
readonly class CheckMonitoredMangasHandler
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private MangaRepositoryInterface $mangaRepository,
|
private MangaRepositoryInterface $mangaRepository,
|
||||||
private MessageBusInterface $commandBus
|
private MessageBusInterface $commandBus,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,7 +20,7 @@ readonly class CheckMonitoredMangasHandler
|
|||||||
{
|
{
|
||||||
$criteria = new MonitoringCriteria(
|
$criteria = new MonitoringCriteria(
|
||||||
enabled: true,
|
enabled: true,
|
||||||
lastCheckBefore: $command->since ?? new DateTimeImmutable('-1 hour')
|
lastCheckBefore: $command->since ?? new \DateTimeImmutable('-1 hour')
|
||||||
);
|
);
|
||||||
|
|
||||||
$monitoredMangas = $this->mangaRepository->findByMonitoringCriteria($criteria);
|
$monitoredMangas = $this->mangaRepository->findByMonitoringCriteria($criteria);
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
namespace App\Domain\Manga\Application\CommandHandler;
|
namespace App\Domain\Manga\Application\CommandHandler;
|
||||||
|
|
||||||
use App\Domain\Manga\Application\Command\CreateMangaFromMangadex;
|
use App\Domain\Manga\Application\Command\CreateMangaFromMangadex;
|
||||||
use App\Domain\Manga\Application\Response\CreateMangaResponse;
|
|
||||||
use App\Domain\Manga\Domain\Contract\Provider\MangaProviderInterface;
|
use App\Domain\Manga\Domain\Contract\Provider\MangaProviderInterface;
|
||||||
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
|
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
|
||||||
use App\Domain\Manga\Domain\Contract\Service\ImageProcessorInterface;
|
use App\Domain\Manga\Domain\Contract\Service\ImageProcessorInterface;
|
||||||
@@ -19,7 +18,7 @@ readonly class CreateMangaFromMangadexHandler
|
|||||||
private MangaProviderInterface $mangaProvider,
|
private MangaProviderInterface $mangaProvider,
|
||||||
private MangaRepositoryInterface $mangaRepository,
|
private MangaRepositoryInterface $mangaRepository,
|
||||||
private ImageProcessorInterface $imageProcessor,
|
private ImageProcessorInterface $imageProcessor,
|
||||||
private EventDispatcherInterface $eventDispatcher
|
private EventDispatcherInterface $eventDispatcher,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,7 +26,7 @@ readonly class CreateMangaFromMangadexHandler
|
|||||||
{
|
{
|
||||||
$manga = $this->mangaProvider->findByExternalId(new ExternalId($command->externalId));
|
$manga = $this->mangaProvider->findByExternalId(new ExternalId($command->externalId));
|
||||||
|
|
||||||
if ($manga === null) {
|
if (null === $manga) {
|
||||||
throw new MangaNotFoundException('Manga not found on Mangadex');
|
throw new MangaNotFoundException('Manga not found on Mangadex');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,7 +40,7 @@ readonly class CreateMangaFromMangadexHandler
|
|||||||
// Met à jour le manga avec les nouveaux chemins d'images
|
// Met à jour le manga avec les nouveaux chemins d'images
|
||||||
$manga->updateImageUrls(new ImageUrls($fullImagePath, $thumbnailPath));
|
$manga->updateImageUrls(new ImageUrls($fullImagePath, $thumbnailPath));
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
throw new \RuntimeException('Erreur lors du traitement de l\'image : ' . $e->getMessage());
|
throw new \RuntimeException('Erreur lors du traitement de l\'image : '.$e->getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->mangaRepository->save($manga);
|
$this->mangaRepository->save($manga);
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ readonly class CreateMangaHandler
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private MangaRepositoryInterface $mangaRepository,
|
private MangaRepositoryInterface $mangaRepository,
|
||||||
private ImageProcessorInterface $imageProcessor,
|
private ImageProcessorInterface $imageProcessor,
|
||||||
private MessageBusInterface $messageBus
|
private MessageBusInterface $messageBus,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ readonly class CreateMangaHandler
|
|||||||
$thumbnailPath = $this->imageProcessor->createThumbnail($fullImagePath);
|
$thumbnailPath = $this->imageProcessor->createThumbnail($fullImagePath);
|
||||||
$manga->updateImageUrls(new ImageUrls($fullImagePath, $thumbnailPath));
|
$manga->updateImageUrls(new ImageUrls($fullImagePath, $thumbnailPath));
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
throw new \RuntimeException('Erreur lors du traitement de l\'image : ' . $e->getMessage());
|
throw new \RuntimeException('Erreur lors du traitement de l\'image : '.$e->getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ namespace App\Domain\Manga\Application\CommandHandler;
|
|||||||
use App\Domain\Manga\Application\Command\DeleteCbz;
|
use App\Domain\Manga\Application\Command\DeleteCbz;
|
||||||
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
|
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
|
||||||
use App\Domain\Manga\Domain\Contract\Service\FileServiceInterface;
|
use App\Domain\Manga\Domain\Contract\Service\FileServiceInterface;
|
||||||
use App\Domain\Manga\Domain\Exception\ChapterNotFoundException;
|
|
||||||
use App\Domain\Manga\Domain\Exception\CbzFileNotFoundException;
|
use App\Domain\Manga\Domain\Exception\CbzFileNotFoundException;
|
||||||
|
use App\Domain\Manga\Domain\Exception\ChapterNotFoundException;
|
||||||
use App\Domain\Shared\Domain\Contract\CommandHandlerInterface;
|
use App\Domain\Shared\Domain\Contract\CommandHandlerInterface;
|
||||||
use App\Domain\Shared\Domain\Contract\CommandInterface;
|
use App\Domain\Shared\Domain\Contract\CommandInterface;
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ readonly class DeleteCbzHandler implements CommandHandlerInterface
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private MangaRepositoryInterface $mangaRepository,
|
private MangaRepositoryInterface $mangaRepository,
|
||||||
private FileServiceInterface $fileService
|
private FileServiceInterface $fileService,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use App\Domain\Shared\Domain\Contract\CommandInterface;
|
|||||||
readonly class DeleteChapterHandler implements CommandHandlerInterface
|
readonly class DeleteChapterHandler implements CommandHandlerInterface
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private MangaRepositoryInterface $mangaRepository
|
private MangaRepositoryInterface $mangaRepository,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use App\Domain\Shared\Domain\Contract\CommandInterface;
|
|||||||
readonly class DeleteMangaHandler implements CommandHandlerInterface
|
readonly class DeleteMangaHandler implements CommandHandlerInterface
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private MangaRepositoryInterface $mangaRepository
|
private MangaRepositoryInterface $mangaRepository,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use App\Domain\Manga\Domain\Model\ValueObject\MangaTitle;
|
|||||||
readonly class EditMangaHandler
|
readonly class EditMangaHandler
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private MangaRepositoryInterface $mangaRepository
|
private MangaRepositoryInterface $mangaRepository,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,35 +23,35 @@ readonly class EditMangaHandler
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update only provided fields (partial update)
|
// Update only provided fields (partial update)
|
||||||
if ($command->title !== null) {
|
if (null !== $command->title) {
|
||||||
$manga->updateTitle(new MangaTitle($command->title));
|
$manga->updateTitle(new MangaTitle($command->title));
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($command->description !== null) {
|
if (null !== $command->description) {
|
||||||
$manga->updateDescription($command->description);
|
$manga->updateDescription($command->description);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($command->author !== null) {
|
if (null !== $command->author) {
|
||||||
$manga->updateAuthor($command->author);
|
$manga->updateAuthor($command->author);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($command->publicationYear !== null) {
|
if (null !== $command->publicationYear) {
|
||||||
$manga->updatePublicationYear($command->publicationYear);
|
$manga->updatePublicationYear($command->publicationYear);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($command->genres !== null) {
|
if (null !== $command->genres) {
|
||||||
$manga->updateGenres($command->genres);
|
$manga->updateGenres($command->genres);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($command->status !== null) {
|
if (null !== $command->status) {
|
||||||
$manga->updateStatus($command->status);
|
$manga->updateStatus($command->status);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($command->rating !== null) {
|
if (null !== $command->rating) {
|
||||||
$manga->setRating($command->rating);
|
$manga->setRating($command->rating);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($command->alternativeSlugs !== null) {
|
if (null !== $command->alternativeSlugs) {
|
||||||
$manga->updateAlternativeSlugs($command->alternativeSlugs);
|
$manga->updateAlternativeSlugs($command->alternativeSlugs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use App\Domain\Manga\Domain\Exception\ChapterNotFoundException;
|
|||||||
readonly class EditMultipleChaptersHandler
|
readonly class EditMultipleChaptersHandler
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private MangaRepositoryInterface $mangaRepository
|
private MangaRepositoryInterface $mangaRepository,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,11 +24,11 @@ readonly class EditMultipleChaptersHandler
|
|||||||
|
|
||||||
$manga = $this->mangaRepository->findById($chapter->getMangaId()->getValue());
|
$manga = $this->mangaRepository->findById($chapter->getMangaId()->getValue());
|
||||||
|
|
||||||
if ($chapterData->title !== null) {
|
if (null !== $chapterData->title) {
|
||||||
$manga->updateChapterTitle($chapter, $chapterData->title);
|
$manga->updateChapterTitle($chapter, $chapterData->title);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($chapterData->volume !== null) {
|
if (null !== $chapterData->volume) {
|
||||||
$manga->updateChapterVolume($chapter, $chapterData->volume);
|
$manga->updateChapterVolume($chapter, $chapterData->volume);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ readonly class FetchMangaChaptersHandler
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private MangaRepositoryInterface $mangaRepository,
|
private MangaRepositoryInterface $mangaRepository,
|
||||||
private ChapterSynchronizationServiceInterface $chapterSynchronizationService
|
private ChapterSynchronizationServiceInterface $chapterSynchronizationService,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,12 +20,12 @@ readonly class FetchMangaChaptersHandler
|
|||||||
{
|
{
|
||||||
$manga = $this->mangaRepository->findById($command->mangaId->getValue());
|
$manga = $this->mangaRepository->findById($command->mangaId->getValue());
|
||||||
|
|
||||||
if ($manga === null) {
|
if (null === $manga) {
|
||||||
throw new MangaNotFoundException();
|
throw new MangaNotFoundException();
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($manga->getExternalId() === null) {
|
if (null === $manga->getExternalId()) {
|
||||||
throw new MangadexApiException("Manga has no external_id");
|
throw new MangadexApiException('Manga has no external_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Synchronisation initiale (pas d'événements)
|
// Synchronisation initiale (pas d'événements)
|
||||||
|
|||||||
@@ -4,15 +4,15 @@ namespace App\Domain\Manga\Application\CommandHandler;
|
|||||||
|
|
||||||
use App\Domain\Manga\Application\Command\ImportChapter;
|
use App\Domain\Manga\Application\Command\ImportChapter;
|
||||||
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
|
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
|
||||||
use App\Domain\Manga\Domain\Exception\MangaNotFoundException;
|
|
||||||
use App\Domain\Manga\Domain\Exception\ChapterNotFoundException;
|
use App\Domain\Manga\Domain\Exception\ChapterNotFoundException;
|
||||||
|
use App\Domain\Manga\Domain\Exception\MangaNotFoundException;
|
||||||
use App\Domain\Shared\Domain\Contract\ImageStorageInterface;
|
use App\Domain\Shared\Domain\Contract\ImageStorageInterface;
|
||||||
|
|
||||||
readonly class ImportChapterHandler
|
readonly class ImportChapterHandler
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private MangaRepositoryInterface $mangaRepository,
|
private MangaRepositoryInterface $mangaRepository,
|
||||||
private ImageStorageInterface $imageStorage
|
private ImageStorageInterface $imageStorage,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,6 +55,6 @@ readonly class ImportChapterHandler
|
|||||||
{
|
{
|
||||||
$zipMagicNumber = "\x50\x4b\x03\x04"; // PK\x03\x04
|
$zipMagicNumber = "\x50\x4b\x03\x04"; // PK\x03\x04
|
||||||
|
|
||||||
return strpos($fileBinary, $zipMagicNumber) === 0;
|
return 0 === strpos($fileBinary, $zipMagicNumber);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ readonly class ImportVolumeHandler
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private MangaRepositoryInterface $mangaRepository,
|
private MangaRepositoryInterface $mangaRepository,
|
||||||
private ImageStorageInterface $imageStorage
|
private ImageStorageInterface $imageStorage,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,9 +35,7 @@ readonly class ImportVolumeHandler
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (empty($chapters)) {
|
if (empty($chapters)) {
|
||||||
throw new \InvalidArgumentException(
|
throw new \InvalidArgumentException("No chapters found for manga {$command->mangaId} in volume {$command->volumeNumber}");
|
||||||
"No chapters found for manga {$command->mangaId} in volume {$command->volumeNumber}"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Extract CBZ into individual images storage (shared directory for all volume chapters)
|
// 4. Extract CBZ into individual images storage (shared directory for all volume chapters)
|
||||||
@@ -56,6 +54,6 @@ readonly class ImportVolumeHandler
|
|||||||
{
|
{
|
||||||
$zipMagicNumber = "\x50\x4b\x03\x04"; // PK\x03\x04
|
$zipMagicNumber = "\x50\x4b\x03\x04"; // PK\x03\x04
|
||||||
|
|
||||||
return strpos($fileBinary, $zipMagicNumber) === 0;
|
return 0 === strpos($fileBinary, $zipMagicNumber);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
|
|||||||
use App\Domain\Manga\Domain\Contract\Service\ChapterSynchronizationServiceInterface;
|
use App\Domain\Manga\Domain\Contract\Service\ChapterSynchronizationServiceInterface;
|
||||||
use App\Domain\Manga\Domain\Event\ChapterReadyForScraping;
|
use App\Domain\Manga\Domain\Event\ChapterReadyForScraping;
|
||||||
use App\Domain\Manga\Domain\Model\ValueObject\ChapterId;
|
use App\Domain\Manga\Domain\Model\ValueObject\ChapterId;
|
||||||
use DateTimeImmutable;
|
|
||||||
use Symfony\Component\Messenger\MessageBusInterface;
|
use Symfony\Component\Messenger\MessageBusInterface;
|
||||||
|
|
||||||
readonly class RefreshMangaChaptersHandler
|
readonly class RefreshMangaChaptersHandler
|
||||||
@@ -15,7 +14,7 @@ readonly class RefreshMangaChaptersHandler
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private MangaRepositoryInterface $mangaRepository,
|
private MangaRepositoryInterface $mangaRepository,
|
||||||
private ChapterSynchronizationServiceInterface $chapterSynchronizationService,
|
private ChapterSynchronizationServiceInterface $chapterSynchronizationService,
|
||||||
private MessageBusInterface $eventBus
|
private MessageBusInterface $eventBus,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,7 +22,7 @@ readonly class RefreshMangaChaptersHandler
|
|||||||
{
|
{
|
||||||
$manga = $this->mangaRepository->findById($command->mangaId->getValue());
|
$manga = $this->mangaRepository->findById($command->mangaId->getValue());
|
||||||
|
|
||||||
if ($manga === null) {
|
if (null === $manga) {
|
||||||
throw new \RuntimeException('Manga not found');
|
throw new \RuntimeException('Manga not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,7 +30,7 @@ readonly class RefreshMangaChaptersHandler
|
|||||||
$newChapterIds = $this->chapterSynchronizationService->synchronizeChapters($manga);
|
$newChapterIds = $this->chapterSynchronizationService->synchronizeChapters($manga);
|
||||||
|
|
||||||
// Mise à jour de la date de monitoring
|
// Mise à jour de la date de monitoring
|
||||||
$manga->updateLastMonitoringCheck(new DateTimeImmutable());
|
$manga->updateLastMonitoringCheck(new \DateTimeImmutable());
|
||||||
$this->mangaRepository->save($manga);
|
$this->mangaRepository->save($manga);
|
||||||
|
|
||||||
// Événement de scraping pour chaque nouveau chapitre
|
// Événement de scraping pour chaque nouveau chapitre
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use App\Domain\Manga\Domain\Exception\MangaNotFoundException;
|
|||||||
readonly class ToggleMangaMonitoringHandler
|
readonly class ToggleMangaMonitoringHandler
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private MangaRepositoryInterface $mangaRepository
|
private MangaRepositoryInterface $mangaRepository,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user