From c245c3544be1c3ff3ba8f983304a16ab182a7fe8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mai=20Th=C3=A0nh?= <62001770+thnhmai06@users.noreply.github.com> Date: Tue, 24 Feb 2026 23:10:20 +0700 Subject: [PATCH 1/8] refactor(backend)!: migrate to JSON-RPC IPC, remove SignalR - Major backend refactor: replaced ASP.NET Core/SignalR host with new stdio JSON-RPC host (`SlideGenerator.Ipc`) - Removed all SignalR, Hangfire, and ASP.NET Core infrastructure, DTOs, and related code - Introduced new projects: `SlideGenerator.Ipc` (JSON-RPC host), `SlideGenerator.Configs` (config system), `SlideGenerator.Generating` (generation runtime) - Overhauled configuration system: new `ConfigManager`, YAML-based config, simplified schema, removed old DTOs/records - Removed all legacy job abstractions, enums, and orchestration logic; new runtime encapsulates generation and validation - Updated build scripts, solution files, and documentation to reference new backend structure and usage - Updated coding conventions: enforced one top-level type per file, added StyleCop.Analyzers - Removed backend test tasks and legacy test projects - Updated Electron integration: frontend now communicates with backend via JSON-RPC IPC, removed all SignalR dependencies - Updated all documentation, guides, and developer workflow to reflect new architecture and APIs - Misc: deleted all code from old Application/Domain/Infrastructure layers, modernized C# usage, improved API docs --- .github/workflows/release.yml | 2 +- .vscode/launch.json | 2 +- CONTRIBUTING.md | 11 +- Taskfile.yml | 8 +- backend/.editorconfig | 18 + backend/.github/copilot-instructions.md | 58 + ...config.sample.yaml => Configs.sample.yaml} | 15 +- backend/Directory.Build.props | 4 + backend/README.md | 14 +- backend/SlideGenerator.slnx | 10 +- backend/construction.md | 58 + backend/docs/en/architecture.md | 86 +- backend/docs/en/deployment.md | 2 +- backend/docs/en/development.md | 9 +- backend/docs/en/job-system.md | 77 +- backend/docs/en/signalr.md | 125 -- backend/docs/en/stdio-jsonrpc.md | 88 + backend/docs/en/usage.md | 22 +- backend/docs/vi/architecture.md | 12 +- backend/docs/vi/deployment.md | 2 +- backend/docs/vi/development.md | 8 +- backend/docs/vi/job-system.md | 4 +- backend/docs/vi/signalr.md | 126 -- backend/docs/vi/usage.md | 22 +- .../Common/Base/DTOs/Responses/Response.cs | 6 - .../Common/Utilities/OutputPathUtils.cs | 24 - .../Features/Configs/ConfigHolder.cs | 27 - .../Configs/DTOs/Components/DownloadConfig.cs | 10 - .../Configs/DTOs/Components/ImageConfig.cs | 24 - .../Configs/DTOs/Components/JobConfig.cs | 6 - .../Configs/DTOs/Components/RetryConfig.cs | 6 - .../Configs/DTOs/Components/ServerConfig.cs | 6 - .../Configs/DTOs/Requests/ConfigUpdate.cs | 59 - .../Configs/DTOs/Requests/ModelControl.cs | 8 - .../DTOs/Responses/Errors/ConfigError.cs | 14 - .../Responses/Successes/ConfigGetSuccess.cs | 14 - .../Successes/ConfigReloadSuccess.cs | 9 - .../Responses/Successes/ConfigResetSuccess.cs | 9 - .../Successes/ConfigUpdateSuccess.cs | 9 - .../Successes/ModelControlSuccess.cs | 13 - .../Responses/Successes/ModelStatusSuccess.cs | 10 - .../Features/Downloads/IDownloadService.cs | 20 - .../Features/Images/IImageService.cs | 47 - .../Collections/IActiveJobCollection.cs | 100 -- .../Collections/ICompletedJobCollection.cs | 39 - .../Contracts/Collections/IJobCollection.cs | 64 - .../Features/Jobs/Contracts/IJobExecutor.cs | 13 - .../Features/Jobs/Contracts/IJobManager.cs | 35 - .../Features/Jobs/Contracts/IJobNotifier.cs | 40 - .../Features/Jobs/DTOs/Requests/JobControl.cs | 16 - .../Features/Jobs/DTOs/Requests/JobCreate.cs | 31 - .../Features/Jobs/DTOs/Requests/JobQuery.cs | 19 - .../Jobs/DTOs/Requests/JobQueryScope.cs | 14 - .../Responses/Successes/JobControlSuccess.cs | 14 - .../Responses/Successes/JobCreateSuccess.cs | 11 - .../DTOs/Responses/Successes/JobDetail.cs | 23 - .../Responses/Successes/JobQuerySuccess.cs | 11 - .../DTOs/Responses/Successes/JobSummary.cs | 17 - .../Features/Jobs/JobSignalRGroups.cs | 23 - .../Features/Jobs/JobStateMapper.cs | 37 - .../DTOs/Components/SheetWorksheetInfo.cs | 6 - .../Workbook/GetWorkbookInfoRequest.cs | 6 - .../Requests/Workbook/SheetWorkbookClose.cs | 6 - .../Workbook/SheetWorkbookGetSheetInfo.cs | 6 - .../Requests/Workbook/SheetWorkbookOpen.cs | 6 - .../Worksheet/SheetWorksheetGetHeaders.cs | 6 - .../Worksheet/SheetWorksheetGetRow.cs | 6 - .../DTOs/Responses/Errors/SheetError.cs | 15 - .../Workbook/OpenBookSheetSuccess.cs | 8 - .../Workbook/SheetWorkbookCloseSuccess.cs | 8 - .../Workbook/SheetWorkbookGetInfoSuccess.cs | 13 - .../SheetWorkbookGetSheetInfoSuccess.cs | 11 - .../SheetWorksheetGetHeadersSuccess.cs | 12 - .../Worksheet/SheetWorksheetGetRowSuccess.cs | 13 - .../Features/Sheets/ISheetService.cs | 16 - .../Slides/DTOs/Components/ShapeDto.cs | 6 - .../DTOs/Components/SlideImageConfig.cs | 8 - .../Slides/DTOs/Components/SlideTextConfig.cs | 6 - .../Slides/DTOs/Enums/ControlAction.cs | 16 - .../GroupProgressNotification.cs | 10 - .../Notifications/GroupStatusNotification.cs | 12 - .../Notifications/JobErrorNotification.cs | 9 - .../DTOs/Notifications/JobLogNotification.cs | 11 - .../Notifications/JobProgressNotification.cs | 12 - .../Notifications/JobStatusNotification.cs | 12 - .../DTOs/Requests/SlideScanPlaceholders.cs | 6 - .../Slides/DTOs/Requests/SlideScanShapes.cs | 6 - .../Slides/DTOs/Requests/SlideScanTemplate.cs | 6 - .../Slides/DTOs/Responses/Errors/Error.cs | 14 - .../Successes/SlideScanPlaceholdersSuccess.cs | 9 - .../Successes/SlideScanShapesSuccess.cs | 10 - .../Successes/SlideScanTemplateSuccess.cs | 10 - .../Features/Slides/ISlideServices.cs | 67 - .../Features/Slides/ISlideTemplateManager.cs | 30 - .../Features/Slides/ISlideWorkingManager.cs | 33 - .../Properties/launchSettings.json | 1 - .../SlideGenerator.Application.csproj | 15 - .../Contracts/IConfigProvider.cs | 15 + .../Entities/Config.DownloadConfig.cs | 52 + .../Entities/Config.ImageConfig.cs | 25 + .../Entities/Config.JobConfig.cs | 12 + .../SlideGenerator.Configs/Entities/Config.cs | 10 + .../Services/ConfigManager.cs | 78 + .../SlideGenerator.Configs.csproj | 23 + .../Features/Configs/Config.DownloadConfig.cs | 49 - .../Features/Configs/Config.ImageConfig.cs | 53 - .../Features/Configs/Config.JobConfig.cs | 9 - .../Features/Configs/Config.ServerConfig.cs | 11 - .../Features/Configs/Config.cs | 16 - .../Downloads/Enums/DownloadStatus.cs | 12 - .../Downloads/Events/DownloadCompletedArgs.cs | 9 - .../Events/DownloadProgressedArgs.cs | 8 - .../Downloads/Events/DownloadStartedArgs.cs | 9 - .../Features/Downloads/IDownloadClient.cs | 17 - .../Features/Downloads/IDownloadTask.cs | 43 - .../Features/IO/IFileSystem.cs | 27 - .../Features/Images/Enums/ImageCropType.cs | 10 - .../Features/Images/Enums/ImageRoiType.cs | 11 - .../Jobs/Components/JobImageConfig.cs | 8 - .../Features/Jobs/Components/JobTextConfig.cs | 6 - .../Features/Jobs/Components/PauseSignal.cs | 44 - .../Features/Jobs/Entities/JobGroup.cs | 187 --- .../Features/Jobs/Entities/JobSheet.cs | 164 -- .../Features/Jobs/Enums/JobGroupStatus.cs | 17 - .../Features/Jobs/Enums/JobSheetStatus.cs | 17 - .../Features/Jobs/Enums/JobState.cs | 17 - .../Features/Jobs/Enums/JobType.cs | 13 - .../Jobs/Interfaces/IJobEventPublisher.cs | 14 - .../Features/Jobs/Interfaces/IJobGroup.cs | 56 - .../Features/Jobs/Interfaces/IJobSheet.cs | 64 - .../Jobs/Interfaces/IJobStateStore.cs | 69 - .../Features/Jobs/Notifications/JobEvent.cs | 22 - .../Features/Jobs/States/GroupJobState.cs | 16 - .../Features/Jobs/States/JobLogEntry.cs | 11 - .../Features/Jobs/States/SheetJobState.cs | 20 - .../Features/Sheets/Interfaces/ISheet.cs | 13 - .../Features/Sheets/Interfaces/ISheetBook.cs | 12 - .../Slides/Components/ImagePreview.cs | 6 - .../Features/Slides/Components/ShapeInfo.cs | 6 - .../Features/Slides/ITemplatePresentation.cs | 15 - .../Features/Slides/IWorkingPresentation.cs | 28 - .../SlideGenerator.Domain.csproj | 12 - backend/src/SlideGenerator.Framework | 2 +- .../Models/GenerateSlidesRequest.cs | 16 + .../Models/ImageConfig.cs | 11 + .../Models/SheetConfig.cs | 8 + .../Models/TemplateSlide.cs | 8 + .../Models/TextConfig.cs | 8 + .../Services/DownloadService.cs | 64 + .../Services/FaceDetectorModelManager.cs | 120 ++ .../Services/GenerateService.cs | 232 +++ .../Services/ValidationService.cs | 49 + .../SlideGenerator.Generating.csproj} | 18 +- .../Common/Base/Service.cs | 11 - .../Common/Logging/LoggingExtensions.cs | 47 - .../Common/Utilities/PathUtils.cs | 44 - .../Common/Utilities/UrlUtils.cs | 62 - .../Features/Configs/ConfigLoader.cs | 56 - .../Downloads/Models/DownloadImageTask.cs | 39 - .../Features/Downloads/Models/DownloadTask.cs | 154 -- .../Downloads/Services/DownloadService.cs | 69 - .../Features/IO/FileSystem.cs | 34 - .../Images/Exceptions/NotImageFileUrl.cs | 7 - .../Features/Images/Services/ImageService.cs | 111 -- .../Services/ResizingFaceDetectorModel.cs | 141 -- .../Hangfire/SheetJobDisplayNameAttribute.cs | 34 - .../Jobs/Hangfire/SheetJobNameRegistry.cs | 51 - .../Jobs/Models/ActiveJobCollection.cs | 651 ------- .../Jobs/Models/CompletedJobCollection.cs | 212 --- .../Jobs/Services/HangfireJobStateStore.cs | 320 ---- .../Features/Jobs/Services/JobExecutor.cs | 433 ----- .../Features/Jobs/Services/JobManager.cs | 290 ---- .../Features/Jobs/Services/JobNotifier.cs | 78 - .../Jobs/Services/JobRestoreHostedService.cs | 24 - .../Sheets/Adapters/WorkbookAdapter.cs | 33 - .../Sheets/Adapters/WorksheetAdapter.cs | 24 - .../Sheets/Exceptions/SheetNotFound.cs | 12 - .../Features/Sheets/Services/SheetService.cs | 44 - .../Adapters/TemplatePresentationAdapter.cs | 72 - .../Adapters/WorkingPresentationAdapter.cs | 31 - .../Exceptions/PresentationNotOpened.cs | 7 - .../Features/Slides/Services/SlideServices.cs | 292 ---- .../Slides/Services/SlideTemplateManager.cs | 59 - .../Slides/Services/SlideWorkingManager.cs | 78 - .../Contracts/Requests/JobIdRequest.cs | 3 + .../Contracts/Requests/ScanFileRequest.cs | 3 + .../Dockerfile | 12 +- .../Endpoints/RpcEndpoint.Configs.cs | 41 + .../Endpoints/RpcEndpoint.Excel.cs | 17 + .../Endpoints/RpcEndpoint.Jobs.cs | 64 + .../Endpoints/RpcEndpoint.Slides.cs | 18 + .../Endpoints/RpcEndpoint.System.cs | 12 + .../Endpoints/RpcEndpoint.cs | 43 + backend/src/SlideGenerator.Ipc/Program.cs | 66 + .../Properties/launchSettings.json | 20 + .../SlideGenerator.Ipc.csproj | 34 + .../appsettings.Development.json | 0 .../appsettings.json | 0 .../src/SlideGenerator.Jobs/BackendService.cs | 746 +++++++++ .../Entities/Jobs/JobControlEntity.cs | 11 + .../Entities/Jobs/JobSnapshotEntity.cs | 20 + .../Entities/Jobs/JobStatusEntity.cs | 14 + .../Entities/Jobs/SheetCheckpointEntity.cs | 18 + .../SlideGenerator.Jobs/JobControlState.cs | 7 + .../JobSnapshotWorkflowDispatcher.cs | 44 + .../PersistJobSnapshotActivity.cs | 22 + .../SlideGenerator.Jobs/RowProcessState.cs | 9 + .../SlideGenerator.Jobs.csproj | 30 + .../Exceptions/Hubs/ConnectionNotFound.cs | 10 - .../Exceptions/Hubs/InvalidRequestFormat.cs | 11 - .../Common/Hubs/Hub.cs | 21 - .../Features/Configs/ConfigHub.cs | 231 --- .../Features/Jobs/JobHub.cs | 486 ------ .../Features/Sheets/SheetHub.cs | 165 -- .../SlideGenerator.Presentation/Program.cs | 168 -- .../Properties/launchSettings.json | 37 - .../SlideGenerator.Presentation.csproj | 21 - .../Models/Sheets/Workbook.cs | 8 + .../Models/Sheets/Worksheet.cs | 9 + .../Models/Slides/Presentation.cs | 8 + .../Models/Slides/Slide.cs | 12 + .../Services/ScanService.cs | 68 + .../SlideGenerator.Scanning.csproj | 23 + .../Domain/JobConfigTests.cs | 14 - .../Domain/JobGroupTests.cs | 100 -- .../Domain/JobSheetTests.cs | 63 - .../Domain/PauseSignalTests.cs | 33 - .../Helpers/ConfigTestHelper.cs | 22 - .../Helpers/FakeJobStateStore.cs | 108 -- .../Helpers/FakeServices.cs | 424 ----- .../Helpers/JsonHelper.cs | 12 - .../Helpers/SignalRTestDoubles.cs | 140 -- .../Helpers/TestFixtures.cs | 91 - .../Infrastructure/ConfigLoaderTests.cs | 61 - .../ResizingFaceDetectorModelTests.cs | 166 -- .../SlideGenerator.Tests/MSTestSettings.cs | 1 - .../Presentation/ConfigHubTests.cs | 137 -- .../Presentation/JobHubSubscriptionTests.cs | 42 - .../Presentation/JobHubTests.cs | 207 --- .../Presentation/SheetHubTests.cs | 117 -- .../SlideGenerator.Tests.csproj | 25 - frontend/.env.example | 4 + frontend/README.md | 13 + frontend/docs/en/development.md | 2 +- frontend/docs/en/overview.md | 22 +- frontend/docs/vi/development.md | 2 +- frontend/docs/vi/overview.md | 22 +- frontend/electron/main.ts | 10 + frontend/electron/main/backend.ts | 77 +- frontend/electron/preload/api.ts | 10 + frontend/package-lock.json | 1491 +++++++---------- frontend/package.json | 30 +- .../features/create-task/CreateTaskMenu.tsx | 6 +- .../components/TemplateInputSection.tsx | 8 +- .../create-task/hooks/useCreateTask.ts | 86 +- .../src/features/create-task/types/index.ts | 4 +- .../src/features/create-task/utils/index.ts | 4 +- .../process/__tests__/ProcessMenu.test.tsx | 2 +- .../src/features/process/hooks/useProcess.ts | 2 +- frontend/src/global.d.ts | 4 + .../shared/contexts/hooks/useJobProvider.ts | 2 +- .../src/shared/contexts/utils/jobUtils.ts | 4 +- frontend/src/shared/locales/en.ts | 4 +- frontend/src/shared/locales/vi.ts | 4 +- .../src/shared/services/backend/clients.ts | 22 +- .../src/shared/services/backend/config/api.ts | 14 +- .../services/backend/health/api.test.ts | 26 +- .../src/shared/services/backend/health/api.ts | 17 +- .../src/shared/services/backend/jobs/api.ts | 34 +- .../shared/services/backend/jobs/normalize.ts | 3 + .../src/shared/services/backend/sheets/api.ts | 344 ++-- frontend/src/shared/services/logging/index.ts | 6 +- .../services/{signalr => rpc}/baseUrl.test.ts | 2 +- .../services/{signalr => rpc}/baseUrl.ts | 31 +- frontend/src/shared/services/rpc/client.ts | 369 ++++ frontend/src/shared/services/rpc/constants.ts | 18 + frontend/src/shared/services/rpcClient.ts | 2 + .../src/shared/services/signalr/client.ts | 307 ---- .../src/shared/services/signalr/constants.ts | 29 - frontend/src/shared/services/signalrClient.ts | 10 - frontend/test-results/junit.xml | 93 + frontend/test/mocks/handlers.ts | 2 +- frontend/vite.config.ts | 5 +- 283 files changed, 3987 insertions(+), 10905 deletions(-) create mode 100644 backend/.editorconfig create mode 100644 backend/.github/copilot-instructions.md rename backend/{backend.config.sample.yaml => Configs.sample.yaml} (74%) create mode 100644 backend/construction.md delete mode 100644 backend/docs/en/signalr.md create mode 100644 backend/docs/en/stdio-jsonrpc.md delete mode 100644 backend/docs/vi/signalr.md delete mode 100644 backend/src/SlideGenerator.Application/Common/Base/DTOs/Responses/Response.cs delete mode 100644 backend/src/SlideGenerator.Application/Common/Utilities/OutputPathUtils.cs delete mode 100644 backend/src/SlideGenerator.Application/Features/Configs/ConfigHolder.cs delete mode 100644 backend/src/SlideGenerator.Application/Features/Configs/DTOs/Components/DownloadConfig.cs delete mode 100644 backend/src/SlideGenerator.Application/Features/Configs/DTOs/Components/ImageConfig.cs delete mode 100644 backend/src/SlideGenerator.Application/Features/Configs/DTOs/Components/JobConfig.cs delete mode 100644 backend/src/SlideGenerator.Application/Features/Configs/DTOs/Components/RetryConfig.cs delete mode 100644 backend/src/SlideGenerator.Application/Features/Configs/DTOs/Components/ServerConfig.cs delete mode 100644 backend/src/SlideGenerator.Application/Features/Configs/DTOs/Requests/ConfigUpdate.cs delete mode 100644 backend/src/SlideGenerator.Application/Features/Configs/DTOs/Requests/ModelControl.cs delete mode 100644 backend/src/SlideGenerator.Application/Features/Configs/DTOs/Responses/Errors/ConfigError.cs delete mode 100644 backend/src/SlideGenerator.Application/Features/Configs/DTOs/Responses/Successes/ConfigGetSuccess.cs delete mode 100644 backend/src/SlideGenerator.Application/Features/Configs/DTOs/Responses/Successes/ConfigReloadSuccess.cs delete mode 100644 backend/src/SlideGenerator.Application/Features/Configs/DTOs/Responses/Successes/ConfigResetSuccess.cs delete mode 100644 backend/src/SlideGenerator.Application/Features/Configs/DTOs/Responses/Successes/ConfigUpdateSuccess.cs delete mode 100644 backend/src/SlideGenerator.Application/Features/Configs/DTOs/Responses/Successes/ModelControlSuccess.cs delete mode 100644 backend/src/SlideGenerator.Application/Features/Configs/DTOs/Responses/Successes/ModelStatusSuccess.cs delete mode 100644 backend/src/SlideGenerator.Application/Features/Downloads/IDownloadService.cs delete mode 100644 backend/src/SlideGenerator.Application/Features/Images/IImageService.cs delete mode 100644 backend/src/SlideGenerator.Application/Features/Jobs/Contracts/Collections/IActiveJobCollection.cs delete mode 100644 backend/src/SlideGenerator.Application/Features/Jobs/Contracts/Collections/ICompletedJobCollection.cs delete mode 100644 backend/src/SlideGenerator.Application/Features/Jobs/Contracts/Collections/IJobCollection.cs delete mode 100644 backend/src/SlideGenerator.Application/Features/Jobs/Contracts/IJobExecutor.cs delete mode 100644 backend/src/SlideGenerator.Application/Features/Jobs/Contracts/IJobManager.cs delete mode 100644 backend/src/SlideGenerator.Application/Features/Jobs/Contracts/IJobNotifier.cs delete mode 100644 backend/src/SlideGenerator.Application/Features/Jobs/DTOs/Requests/JobControl.cs delete mode 100644 backend/src/SlideGenerator.Application/Features/Jobs/DTOs/Requests/JobCreate.cs delete mode 100644 backend/src/SlideGenerator.Application/Features/Jobs/DTOs/Requests/JobQuery.cs delete mode 100644 backend/src/SlideGenerator.Application/Features/Jobs/DTOs/Requests/JobQueryScope.cs delete mode 100644 backend/src/SlideGenerator.Application/Features/Jobs/DTOs/Responses/Successes/JobControlSuccess.cs delete mode 100644 backend/src/SlideGenerator.Application/Features/Jobs/DTOs/Responses/Successes/JobCreateSuccess.cs delete mode 100644 backend/src/SlideGenerator.Application/Features/Jobs/DTOs/Responses/Successes/JobDetail.cs delete mode 100644 backend/src/SlideGenerator.Application/Features/Jobs/DTOs/Responses/Successes/JobQuerySuccess.cs delete mode 100644 backend/src/SlideGenerator.Application/Features/Jobs/DTOs/Responses/Successes/JobSummary.cs delete mode 100644 backend/src/SlideGenerator.Application/Features/Jobs/JobSignalRGroups.cs delete mode 100644 backend/src/SlideGenerator.Application/Features/Jobs/JobStateMapper.cs delete mode 100644 backend/src/SlideGenerator.Application/Features/Sheets/DTOs/Components/SheetWorksheetInfo.cs delete mode 100644 backend/src/SlideGenerator.Application/Features/Sheets/DTOs/Requests/Workbook/GetWorkbookInfoRequest.cs delete mode 100644 backend/src/SlideGenerator.Application/Features/Sheets/DTOs/Requests/Workbook/SheetWorkbookClose.cs delete mode 100644 backend/src/SlideGenerator.Application/Features/Sheets/DTOs/Requests/Workbook/SheetWorkbookGetSheetInfo.cs delete mode 100644 backend/src/SlideGenerator.Application/Features/Sheets/DTOs/Requests/Workbook/SheetWorkbookOpen.cs delete mode 100644 backend/src/SlideGenerator.Application/Features/Sheets/DTOs/Requests/Worksheet/SheetWorksheetGetHeaders.cs delete mode 100644 backend/src/SlideGenerator.Application/Features/Sheets/DTOs/Requests/Worksheet/SheetWorksheetGetRow.cs delete mode 100644 backend/src/SlideGenerator.Application/Features/Sheets/DTOs/Responses/Errors/SheetError.cs delete mode 100644 backend/src/SlideGenerator.Application/Features/Sheets/DTOs/Responses/Successes/Workbook/OpenBookSheetSuccess.cs delete mode 100644 backend/src/SlideGenerator.Application/Features/Sheets/DTOs/Responses/Successes/Workbook/SheetWorkbookCloseSuccess.cs delete mode 100644 backend/src/SlideGenerator.Application/Features/Sheets/DTOs/Responses/Successes/Workbook/SheetWorkbookGetInfoSuccess.cs delete mode 100644 backend/src/SlideGenerator.Application/Features/Sheets/DTOs/Responses/Successes/Workbook/SheetWorkbookGetSheetInfoSuccess.cs delete mode 100644 backend/src/SlideGenerator.Application/Features/Sheets/DTOs/Responses/Successes/Worksheet/SheetWorksheetGetHeadersSuccess.cs delete mode 100644 backend/src/SlideGenerator.Application/Features/Sheets/DTOs/Responses/Successes/Worksheet/SheetWorksheetGetRowSuccess.cs delete mode 100644 backend/src/SlideGenerator.Application/Features/Sheets/ISheetService.cs delete mode 100644 backend/src/SlideGenerator.Application/Features/Slides/DTOs/Components/ShapeDto.cs delete mode 100644 backend/src/SlideGenerator.Application/Features/Slides/DTOs/Components/SlideImageConfig.cs delete mode 100644 backend/src/SlideGenerator.Application/Features/Slides/DTOs/Components/SlideTextConfig.cs delete mode 100644 backend/src/SlideGenerator.Application/Features/Slides/DTOs/Enums/ControlAction.cs delete mode 100644 backend/src/SlideGenerator.Application/Features/Slides/DTOs/Notifications/GroupProgressNotification.cs delete mode 100644 backend/src/SlideGenerator.Application/Features/Slides/DTOs/Notifications/GroupStatusNotification.cs delete mode 100644 backend/src/SlideGenerator.Application/Features/Slides/DTOs/Notifications/JobErrorNotification.cs delete mode 100644 backend/src/SlideGenerator.Application/Features/Slides/DTOs/Notifications/JobLogNotification.cs delete mode 100644 backend/src/SlideGenerator.Application/Features/Slides/DTOs/Notifications/JobProgressNotification.cs delete mode 100644 backend/src/SlideGenerator.Application/Features/Slides/DTOs/Notifications/JobStatusNotification.cs delete mode 100644 backend/src/SlideGenerator.Application/Features/Slides/DTOs/Requests/SlideScanPlaceholders.cs delete mode 100644 backend/src/SlideGenerator.Application/Features/Slides/DTOs/Requests/SlideScanShapes.cs delete mode 100644 backend/src/SlideGenerator.Application/Features/Slides/DTOs/Requests/SlideScanTemplate.cs delete mode 100644 backend/src/SlideGenerator.Application/Features/Slides/DTOs/Responses/Errors/Error.cs delete mode 100644 backend/src/SlideGenerator.Application/Features/Slides/DTOs/Responses/Successes/SlideScanPlaceholdersSuccess.cs delete mode 100644 backend/src/SlideGenerator.Application/Features/Slides/DTOs/Responses/Successes/SlideScanShapesSuccess.cs delete mode 100644 backend/src/SlideGenerator.Application/Features/Slides/DTOs/Responses/Successes/SlideScanTemplateSuccess.cs delete mode 100644 backend/src/SlideGenerator.Application/Features/Slides/ISlideServices.cs delete mode 100644 backend/src/SlideGenerator.Application/Features/Slides/ISlideTemplateManager.cs delete mode 100644 backend/src/SlideGenerator.Application/Features/Slides/ISlideWorkingManager.cs delete mode 100644 backend/src/SlideGenerator.Application/Properties/launchSettings.json delete mode 100644 backend/src/SlideGenerator.Application/SlideGenerator.Application.csproj create mode 100644 backend/src/SlideGenerator.Configs/Contracts/IConfigProvider.cs create mode 100644 backend/src/SlideGenerator.Configs/Entities/Config.DownloadConfig.cs create mode 100644 backend/src/SlideGenerator.Configs/Entities/Config.ImageConfig.cs create mode 100644 backend/src/SlideGenerator.Configs/Entities/Config.JobConfig.cs create mode 100644 backend/src/SlideGenerator.Configs/Entities/Config.cs create mode 100644 backend/src/SlideGenerator.Configs/Services/ConfigManager.cs create mode 100644 backend/src/SlideGenerator.Configs/SlideGenerator.Configs.csproj delete mode 100644 backend/src/SlideGenerator.Domain/Features/Configs/Config.DownloadConfig.cs delete mode 100644 backend/src/SlideGenerator.Domain/Features/Configs/Config.ImageConfig.cs delete mode 100644 backend/src/SlideGenerator.Domain/Features/Configs/Config.JobConfig.cs delete mode 100644 backend/src/SlideGenerator.Domain/Features/Configs/Config.ServerConfig.cs delete mode 100644 backend/src/SlideGenerator.Domain/Features/Configs/Config.cs delete mode 100644 backend/src/SlideGenerator.Domain/Features/Downloads/Enums/DownloadStatus.cs delete mode 100644 backend/src/SlideGenerator.Domain/Features/Downloads/Events/DownloadCompletedArgs.cs delete mode 100644 backend/src/SlideGenerator.Domain/Features/Downloads/Events/DownloadProgressedArgs.cs delete mode 100644 backend/src/SlideGenerator.Domain/Features/Downloads/Events/DownloadStartedArgs.cs delete mode 100644 backend/src/SlideGenerator.Domain/Features/Downloads/IDownloadClient.cs delete mode 100644 backend/src/SlideGenerator.Domain/Features/Downloads/IDownloadTask.cs delete mode 100644 backend/src/SlideGenerator.Domain/Features/IO/IFileSystem.cs delete mode 100644 backend/src/SlideGenerator.Domain/Features/Images/Enums/ImageCropType.cs delete mode 100644 backend/src/SlideGenerator.Domain/Features/Images/Enums/ImageRoiType.cs delete mode 100644 backend/src/SlideGenerator.Domain/Features/Jobs/Components/JobImageConfig.cs delete mode 100644 backend/src/SlideGenerator.Domain/Features/Jobs/Components/JobTextConfig.cs delete mode 100644 backend/src/SlideGenerator.Domain/Features/Jobs/Components/PauseSignal.cs delete mode 100644 backend/src/SlideGenerator.Domain/Features/Jobs/Entities/JobGroup.cs delete mode 100644 backend/src/SlideGenerator.Domain/Features/Jobs/Entities/JobSheet.cs delete mode 100644 backend/src/SlideGenerator.Domain/Features/Jobs/Enums/JobGroupStatus.cs delete mode 100644 backend/src/SlideGenerator.Domain/Features/Jobs/Enums/JobSheetStatus.cs delete mode 100644 backend/src/SlideGenerator.Domain/Features/Jobs/Enums/JobState.cs delete mode 100644 backend/src/SlideGenerator.Domain/Features/Jobs/Enums/JobType.cs delete mode 100644 backend/src/SlideGenerator.Domain/Features/Jobs/Interfaces/IJobEventPublisher.cs delete mode 100644 backend/src/SlideGenerator.Domain/Features/Jobs/Interfaces/IJobGroup.cs delete mode 100644 backend/src/SlideGenerator.Domain/Features/Jobs/Interfaces/IJobSheet.cs delete mode 100644 backend/src/SlideGenerator.Domain/Features/Jobs/Interfaces/IJobStateStore.cs delete mode 100644 backend/src/SlideGenerator.Domain/Features/Jobs/Notifications/JobEvent.cs delete mode 100644 backend/src/SlideGenerator.Domain/Features/Jobs/States/GroupJobState.cs delete mode 100644 backend/src/SlideGenerator.Domain/Features/Jobs/States/JobLogEntry.cs delete mode 100644 backend/src/SlideGenerator.Domain/Features/Jobs/States/SheetJobState.cs delete mode 100644 backend/src/SlideGenerator.Domain/Features/Sheets/Interfaces/ISheet.cs delete mode 100644 backend/src/SlideGenerator.Domain/Features/Sheets/Interfaces/ISheetBook.cs delete mode 100644 backend/src/SlideGenerator.Domain/Features/Slides/Components/ImagePreview.cs delete mode 100644 backend/src/SlideGenerator.Domain/Features/Slides/Components/ShapeInfo.cs delete mode 100644 backend/src/SlideGenerator.Domain/Features/Slides/ITemplatePresentation.cs delete mode 100644 backend/src/SlideGenerator.Domain/Features/Slides/IWorkingPresentation.cs delete mode 100644 backend/src/SlideGenerator.Domain/SlideGenerator.Domain.csproj create mode 100644 backend/src/SlideGenerator.Generating/Models/GenerateSlidesRequest.cs create mode 100644 backend/src/SlideGenerator.Generating/Models/ImageConfig.cs create mode 100644 backend/src/SlideGenerator.Generating/Models/SheetConfig.cs create mode 100644 backend/src/SlideGenerator.Generating/Models/TemplateSlide.cs create mode 100644 backend/src/SlideGenerator.Generating/Models/TextConfig.cs create mode 100644 backend/src/SlideGenerator.Generating/Services/DownloadService.cs create mode 100644 backend/src/SlideGenerator.Generating/Services/FaceDetectorModelManager.cs create mode 100644 backend/src/SlideGenerator.Generating/Services/GenerateService.cs create mode 100644 backend/src/SlideGenerator.Generating/Services/ValidationService.cs rename backend/src/{SlideGenerator.Infrastructure/SlideGenerator.Infrastructure.csproj => SlideGenerator.Generating/SlideGenerator.Generating.csproj} (63%) delete mode 100644 backend/src/SlideGenerator.Infrastructure/Common/Base/Service.cs delete mode 100644 backend/src/SlideGenerator.Infrastructure/Common/Logging/LoggingExtensions.cs delete mode 100644 backend/src/SlideGenerator.Infrastructure/Common/Utilities/PathUtils.cs delete mode 100644 backend/src/SlideGenerator.Infrastructure/Common/Utilities/UrlUtils.cs delete mode 100644 backend/src/SlideGenerator.Infrastructure/Features/Configs/ConfigLoader.cs delete mode 100644 backend/src/SlideGenerator.Infrastructure/Features/Downloads/Models/DownloadImageTask.cs delete mode 100644 backend/src/SlideGenerator.Infrastructure/Features/Downloads/Models/DownloadTask.cs delete mode 100644 backend/src/SlideGenerator.Infrastructure/Features/Downloads/Services/DownloadService.cs delete mode 100644 backend/src/SlideGenerator.Infrastructure/Features/IO/FileSystem.cs delete mode 100644 backend/src/SlideGenerator.Infrastructure/Features/Images/Exceptions/NotImageFileUrl.cs delete mode 100644 backend/src/SlideGenerator.Infrastructure/Features/Images/Services/ImageService.cs delete mode 100644 backend/src/SlideGenerator.Infrastructure/Features/Images/Services/ResizingFaceDetectorModel.cs delete mode 100644 backend/src/SlideGenerator.Infrastructure/Features/Jobs/Hangfire/SheetJobDisplayNameAttribute.cs delete mode 100644 backend/src/SlideGenerator.Infrastructure/Features/Jobs/Hangfire/SheetJobNameRegistry.cs delete mode 100644 backend/src/SlideGenerator.Infrastructure/Features/Jobs/Models/ActiveJobCollection.cs delete mode 100644 backend/src/SlideGenerator.Infrastructure/Features/Jobs/Models/CompletedJobCollection.cs delete mode 100644 backend/src/SlideGenerator.Infrastructure/Features/Jobs/Services/HangfireJobStateStore.cs delete mode 100644 backend/src/SlideGenerator.Infrastructure/Features/Jobs/Services/JobExecutor.cs delete mode 100644 backend/src/SlideGenerator.Infrastructure/Features/Jobs/Services/JobManager.cs delete mode 100644 backend/src/SlideGenerator.Infrastructure/Features/Jobs/Services/JobNotifier.cs delete mode 100644 backend/src/SlideGenerator.Infrastructure/Features/Jobs/Services/JobRestoreHostedService.cs delete mode 100644 backend/src/SlideGenerator.Infrastructure/Features/Sheets/Adapters/WorkbookAdapter.cs delete mode 100644 backend/src/SlideGenerator.Infrastructure/Features/Sheets/Adapters/WorksheetAdapter.cs delete mode 100644 backend/src/SlideGenerator.Infrastructure/Features/Sheets/Exceptions/SheetNotFound.cs delete mode 100644 backend/src/SlideGenerator.Infrastructure/Features/Sheets/Services/SheetService.cs delete mode 100644 backend/src/SlideGenerator.Infrastructure/Features/Slides/Adapters/TemplatePresentationAdapter.cs delete mode 100644 backend/src/SlideGenerator.Infrastructure/Features/Slides/Adapters/WorkingPresentationAdapter.cs delete mode 100644 backend/src/SlideGenerator.Infrastructure/Features/Slides/Exceptions/PresentationNotOpened.cs delete mode 100644 backend/src/SlideGenerator.Infrastructure/Features/Slides/Services/SlideServices.cs delete mode 100644 backend/src/SlideGenerator.Infrastructure/Features/Slides/Services/SlideTemplateManager.cs delete mode 100644 backend/src/SlideGenerator.Infrastructure/Features/Slides/Services/SlideWorkingManager.cs create mode 100644 backend/src/SlideGenerator.Ipc/Contracts/Requests/JobIdRequest.cs create mode 100644 backend/src/SlideGenerator.Ipc/Contracts/Requests/ScanFileRequest.cs rename backend/src/{SlideGenerator.Presentation => SlideGenerator.Ipc}/Dockerfile (63%) create mode 100644 backend/src/SlideGenerator.Ipc/Endpoints/RpcEndpoint.Configs.cs create mode 100644 backend/src/SlideGenerator.Ipc/Endpoints/RpcEndpoint.Excel.cs create mode 100644 backend/src/SlideGenerator.Ipc/Endpoints/RpcEndpoint.Jobs.cs create mode 100644 backend/src/SlideGenerator.Ipc/Endpoints/RpcEndpoint.Slides.cs create mode 100644 backend/src/SlideGenerator.Ipc/Endpoints/RpcEndpoint.System.cs create mode 100644 backend/src/SlideGenerator.Ipc/Endpoints/RpcEndpoint.cs create mode 100644 backend/src/SlideGenerator.Ipc/Program.cs create mode 100644 backend/src/SlideGenerator.Ipc/Properties/launchSettings.json create mode 100644 backend/src/SlideGenerator.Ipc/SlideGenerator.Ipc.csproj rename backend/src/{SlideGenerator.Presentation => SlideGenerator.Ipc}/appsettings.Development.json (100%) rename backend/src/{SlideGenerator.Presentation => SlideGenerator.Ipc}/appsettings.json (100%) create mode 100644 backend/src/SlideGenerator.Jobs/BackendService.cs create mode 100644 backend/src/SlideGenerator.Jobs/Entities/Jobs/JobControlEntity.cs create mode 100644 backend/src/SlideGenerator.Jobs/Entities/Jobs/JobSnapshotEntity.cs create mode 100644 backend/src/SlideGenerator.Jobs/Entities/Jobs/JobStatusEntity.cs create mode 100644 backend/src/SlideGenerator.Jobs/Entities/Jobs/SheetCheckpointEntity.cs create mode 100644 backend/src/SlideGenerator.Jobs/JobControlState.cs create mode 100644 backend/src/SlideGenerator.Jobs/JobSnapshotWorkflowDispatcher.cs create mode 100644 backend/src/SlideGenerator.Jobs/PersistJobSnapshotActivity.cs create mode 100644 backend/src/SlideGenerator.Jobs/RowProcessState.cs create mode 100644 backend/src/SlideGenerator.Jobs/SlideGenerator.Jobs.csproj delete mode 100644 backend/src/SlideGenerator.Presentation/Common/Exceptions/Hubs/ConnectionNotFound.cs delete mode 100644 backend/src/SlideGenerator.Presentation/Common/Exceptions/Hubs/InvalidRequestFormat.cs delete mode 100644 backend/src/SlideGenerator.Presentation/Common/Hubs/Hub.cs delete mode 100644 backend/src/SlideGenerator.Presentation/Features/Configs/ConfigHub.cs delete mode 100644 backend/src/SlideGenerator.Presentation/Features/Jobs/JobHub.cs delete mode 100644 backend/src/SlideGenerator.Presentation/Features/Sheets/SheetHub.cs delete mode 100644 backend/src/SlideGenerator.Presentation/Program.cs delete mode 100644 backend/src/SlideGenerator.Presentation/Properties/launchSettings.json delete mode 100644 backend/src/SlideGenerator.Presentation/SlideGenerator.Presentation.csproj create mode 100644 backend/src/SlideGenerator.Scanning/Models/Sheets/Workbook.cs create mode 100644 backend/src/SlideGenerator.Scanning/Models/Sheets/Worksheet.cs create mode 100644 backend/src/SlideGenerator.Scanning/Models/Slides/Presentation.cs create mode 100644 backend/src/SlideGenerator.Scanning/Models/Slides/Slide.cs create mode 100644 backend/src/SlideGenerator.Scanning/Services/ScanService.cs create mode 100644 backend/src/SlideGenerator.Scanning/SlideGenerator.Scanning.csproj delete mode 100644 backend/tests/SlideGenerator.Tests/Domain/JobConfigTests.cs delete mode 100644 backend/tests/SlideGenerator.Tests/Domain/JobGroupTests.cs delete mode 100644 backend/tests/SlideGenerator.Tests/Domain/JobSheetTests.cs delete mode 100644 backend/tests/SlideGenerator.Tests/Domain/PauseSignalTests.cs delete mode 100644 backend/tests/SlideGenerator.Tests/Helpers/ConfigTestHelper.cs delete mode 100644 backend/tests/SlideGenerator.Tests/Helpers/FakeJobStateStore.cs delete mode 100644 backend/tests/SlideGenerator.Tests/Helpers/FakeServices.cs delete mode 100644 backend/tests/SlideGenerator.Tests/Helpers/JsonHelper.cs delete mode 100644 backend/tests/SlideGenerator.Tests/Helpers/SignalRTestDoubles.cs delete mode 100644 backend/tests/SlideGenerator.Tests/Helpers/TestFixtures.cs delete mode 100644 backend/tests/SlideGenerator.Tests/Infrastructure/ConfigLoaderTests.cs delete mode 100644 backend/tests/SlideGenerator.Tests/Infrastructure/ResizingFaceDetectorModelTests.cs delete mode 100644 backend/tests/SlideGenerator.Tests/MSTestSettings.cs delete mode 100644 backend/tests/SlideGenerator.Tests/Presentation/ConfigHubTests.cs delete mode 100644 backend/tests/SlideGenerator.Tests/Presentation/JobHubSubscriptionTests.cs delete mode 100644 backend/tests/SlideGenerator.Tests/Presentation/JobHubTests.cs delete mode 100644 backend/tests/SlideGenerator.Tests/Presentation/SheetHubTests.cs delete mode 100644 backend/tests/SlideGenerator.Tests/SlideGenerator.Tests.csproj create mode 100644 frontend/.env.example rename frontend/src/shared/services/{signalr => rpc}/baseUrl.test.ts (97%) rename frontend/src/shared/services/{signalr => rpc}/baseUrl.ts (52%) create mode 100644 frontend/src/shared/services/rpc/client.ts create mode 100644 frontend/src/shared/services/rpc/constants.ts create mode 100644 frontend/src/shared/services/rpcClient.ts delete mode 100644 frontend/src/shared/services/signalr/client.ts delete mode 100644 frontend/src/shared/services/signalr/constants.ts delete mode 100644 frontend/src/shared/services/signalrClient.ts create mode 100644 frontend/test-results/junit.xml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b3d6e5f2..210a2683 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -51,7 +51,7 @@ jobs: RID=linux-x64 fi - dotnet publish backend/src/SlideGenerator.Presentation/SlideGenerator.Presentation.csproj \ + dotnet publish backend/src/SlideGenerator.Ipc/SlideGenerator.Ipc.csproj \ -c Release \ -r $RID \ --self-contained false \ diff --git a/.vscode/launch.json b/.vscode/launch.json index 18746450..33472f8b 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -39,7 +39,7 @@ "args": [ "run", "--project", - "${workspaceFolder}/backend/src/SlideGenerator.Presentation/SlideGenerator.Presentation.csproj" + "${workspaceFolder}/backend/src/SlideGenerator.Ipc/SlideGenerator.Ipc.csproj" ], "cwd": "${workspaceFolder}", "console": "integratedTerminal", diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e63bfcfd..834a152f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -88,7 +88,7 @@ git pull 1. **Via Visual Studio:** - Open `SlideGenerator.sln`. - - Set `SlideGenerator.Presentation` as the startup project. + - Set `SlideGenerator.Ipc` as the startup project. - Start Debugging (F5). 2. **Via VS Code:** @@ -98,7 +98,7 @@ git pull 3. **Via CLI:** ```bash cd backend - dotnet run --project src/SlideGenerator.Presentation + dotnet run --project src/SlideGenerator.Ipc ``` #### Frontend @@ -162,6 +162,13 @@ task format This will run `dotnet format` for the backend and `npm run format` for the frontend. +Backend coding convention (including `backend/src` and `backend/tests`): + +- Prefer **one standalone top-level type per file** (`class`, `interface`, `record`, `enum`, `struct`). +- Nested composite types inside their parent type are allowed in the same file. +- Avoid adding 2+ standalone top-level types in the same `.cs` file unless there is a strong reason. +- The backend includes analyzer rule `SA1402` as a suggestion to remind this convention during development. + ## Documentation **Backend:** diff --git a/Taskfile.yml b/Taskfile.yml index a7b9506b..7b7b578d 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -3,7 +3,7 @@ version: '3' vars: RUNTIME: '{{default "win-x64" .RUNTIME}}' BACKEND_BUILD_DIR: 'frontend/backend' - BACKEND_PROJECT: 'backend/src/SlideGenerator.Presentation/SlideGenerator.Presentation.csproj' + BACKEND_PROJECT: 'backend/src/SlideGenerator.Ipc/SlideGenerator.Ipc.csproj' BACKEND_SOLUTION: 'backend/SlideGenerator.slnx' tasks: @@ -46,11 +46,9 @@ tasks: - task: test:frontend test:backend: - desc: Run Backend unit tests + desc: Backend tests removed cmds: - - echo "Running Backend Tests..." - - dotnet restore {{.BACKEND_SOLUTION}} - - dotnet test {{.BACKEND_SOLUTION}} --no-restore --logger "trx;LogFileName=backend-tests.trx" --results-directory backend/TestResults + - echo "Backend tests removed" test:frontend: desc: Run Frontend unit tests diff --git a/backend/.editorconfig b/backend/.editorconfig new file mode 100644 index 00000000..b7fd9e9d --- /dev/null +++ b/backend/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*.cs] +# Prefer one standalone top-level type per file on backend. +# Nested composite types inside their parent are allowed. +dotnet_analyzer_diagnostic.category-StyleCop.CSharp.DocumentationRules.severity = none +dotnet_analyzer_diagnostic.category-StyleCop.CSharp.LayoutRules.severity = none +dotnet_analyzer_diagnostic.category-StyleCop.CSharp.MaintainabilityRules.severity = none +dotnet_analyzer_diagnostic.category-StyleCop.CSharp.NamingRules.severity = none +dotnet_analyzer_diagnostic.category-StyleCop.CSharp.OrderingRules.severity = none +dotnet_analyzer_diagnostic.category-StyleCop.CSharp.ReadabilityRules.severity = none +dotnet_analyzer_diagnostic.category-StyleCop.CSharp.SpacingRules.severity = none +dotnet_diagnostic.SA0001.severity = none +dotnet_diagnostic.SA1402.severity = suggestion + +[tests/**/*.cs] +# Apply the same standalone-type convention for backend test code. +dotnet_diagnostic.SA1402.severity = suggestion diff --git a/backend/.github/copilot-instructions.md b/backend/.github/copilot-instructions.md new file mode 100644 index 00000000..916a179c --- /dev/null +++ b/backend/.github/copilot-instructions.md @@ -0,0 +1,58 @@ +# Copilot Instructions + +## Source of Truth +- Read and follow [Constructon](../construction.md) before making architectural decisions. +- If `copilot-instructions.md` and `Constructon` differ, prefer `Constructon` for project-specific architecture/runtime rules. + +## General Guidelines +- Keep changes minimal and scoped to the requested feature. +- Prefer fixing root causes over adding temporary workarounds. +- Do not introduce unrelated refactors while implementing a task. +- Keep public APIs stable unless the task explicitly requests breaking changes. + +## Code Style +- Use C# 12+ style already used in this repo (`sealed`, file-scoped namespaces, explicit async APIs). +- Use meaningful names; avoid one-letter variables except in trivial loops. +- Add XML doc comments for public types and public methods in touched files. +- Prefer expression clarity over clever code. +- Preserve existing indentation and formatting conventions. +- Keep method bodies short and intention-revealing; extract private helpers when a method handles multiple concerns. +- Validate external inputs early (guard clauses) and fail fast with explicit exception types. +- Prefer `async`/`await` end-to-end for I/O paths; avoid sync-over-async patterns (`.Result`, `.Wait()`). +- Return structured results/models instead of loosely typed objects or magic dictionaries. +- Use `ILogger` for operational logs; keep logs concise, contextual, and free of sensitive data. +- Avoid hidden side effects: methods should do what their names describe and keep state transitions explicit. +- Follow object-oriented design by default: + - Encapsulate behavior in classes/services instead of top-level script style. + - Keep methods focused and single-purpose; extract private helper methods when logic grows. + - Prefer dependency inversion (interfaces/contracts) for cross-project dependencies. + - Keep mutable state private and expose minimal public surface. + +## Project-Specific Rules +- Keep architecture boundaries strict: + - `Framework`: reusable low-level features, no app orchestration. + - `Generating`: runtime orchestration services for generation flow. + - `Jobs`: workflow and persistence orchestration. + - `Scanning`: slide/sheet metadata scanning. + - `Ipc`: JSON-RPC transport adapter. + - `Configs`: configuration contracts/entities/services only. +- Configuration access for non-`Configs` projects currently uses `IConfigProvider` mapping from singleton `ConfigManager`. + - Register `ConfigManager` once in DI and map provider interfaces from it. + - Avoid passing raw `Config` as root dependency unless taking a runtime snapshot in an internal service. +- Prefer Dependency Injection from `Program.cs` (`Microsoft.Extensions.DependencyInjection`). + - Avoid manual `new` for service wiring in constructors. +- Face detection lifecycle contract: + - Model initialization ownership is in `FaceDetectorModelManager`. + - In `Framework`, `DetectAsync` must throw when model is not initialized. + - `Framework` returns all detections; score filtering is handled by caller/business layer. +- Download behavior: + - Use `DownloadService` (Downloader library) for remote downloads in Generate flow. + - Avoid direct ad-hoc `HttpClient` download logic in generation pipeline. +- IPC endpoint structure: + - Keep request DTO in `SlideGenerator.Ipc/Contracts/Requests`. + - Keep RPC handlers in partial `RpcEndpoint.*.cs` files and call `BackendService` for orchestration. +- Framework reuse: + - If logic already exists in `Framework` services, use it instead of duplicating logic in `Scanning`, `Generating`, or `Jobs`. +- Data shape conventions: + - Use `Entities` for domain/runtime entities. + - Use `Models` for supporting option/value model types. \ No newline at end of file diff --git a/backend/backend.config.sample.yaml b/backend/Configs.sample.yaml similarity index 74% rename from backend/backend.config.sample.yaml rename to backend/Configs.sample.yaml index a547c73f..bc5180e6 100644 --- a/backend/backend.config.sample.yaml +++ b/backend/Configs.sample.yaml @@ -1,13 +1,6 @@ -# SlideGenerator Backend Configuration -# This file configures the backend server and image processing options +# SlideGenerator Configuration -# Server configuration -server: - host: 127.0.0.1 - port: 5000 - debug: false - -# Download configuration +## Download configuration download: max_chunks: 5 limit_bytes_per_second: 0 @@ -22,11 +15,11 @@ download: password: '' domain: '' -# Job configuration +## Jobs configuration job: max_concurrent_jobs: 2 -# Image processing configuration +## Image processing configuration image: # Face detection settings face: diff --git a/backend/Directory.Build.props b/backend/Directory.Build.props index 3d0028f7..5c10ecc6 100644 --- a/backend/Directory.Build.props +++ b/backend/Directory.Build.props @@ -2,4 +2,8 @@ $(NoWarn);NU1902;NU1903;NETSDK1206 + + + + diff --git a/backend/README.md b/backend/README.md index 4aecfc2b..23ac2481 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,6 +1,6 @@ # SlideGenerator Backend -The robust backend service that powers SlideGenerator, built with **ASP.NET Core 10** and **SignalR**. It handles slide generation logic, job management, and background processing with resilience and performance in mind. +The robust backend service that powers SlideGenerator, built with **.NET 10** and **stdio JSON-RPC**. It handles slide generation logic, job management, and background processing with resilience and performance in mind. ## Table of Contents @@ -10,7 +10,7 @@ The robust backend service that powers SlideGenerator, built with **ASP.NET Core - [Architecture](#architecture) - [Key Systems](#key-systems) - [Job System](#job-system) - - [SignalR API](#signalr-api) + - [Stdio JSON-RPC API](#stdio-json-rpc-api) - [Getting Started](#getting-started) - [Configuration](#configuration) - [Usage](#usage) @@ -24,7 +24,7 @@ The robust backend service that powers SlideGenerator, built with **ASP.NET Core This directory contains the backend source code, structured as a Clean Architecture solution. - **Target Runtime:** .NET 10 -- **Host:** ASP.NET Core Web API + SignalR +- **Host:** Console host + StreamJsonRpc over stdio - **Background Jobs:** Hangfire (Persistent job execution) - **Database:** SQLite (Job state storage) - **Architectural Pattern:** Clean Architecture (Domain, Application, Infrastructure, Presentation) @@ -44,13 +44,13 @@ The heart of the application. It manages the lifecycle of slide generation tasks - **Features:** Parallel processing, Pause/Resume/Cancel capabilities, Crash recovery. - **Learn more:** [Job System Documentation](docs/en/job-system.md) -### SignalR API +### Stdio JSON-RPC API -Real-time bi-directional communication with the Frontend. +Bidirectional request/notification channel between Frontend and Backend. -- **Protocol:** WebSocket (primary) +- **Protocol:** JSON-RPC 2.0 over stdio - **Features:** Real-time progress updates, Job control commands, Configuration sync. -- **Learn more:** [SignalR API Documentation](docs/en/signalr.md) +- **Learn more:** [Stdio JSON-RPC API Documentation](docs/en/stdio-jsonrpc.md) ## Getting Started diff --git a/backend/SlideGenerator.slnx b/backend/SlideGenerator.slnx index 28fc9562..b1485d8e 100644 --- a/backend/SlideGenerator.slnx +++ b/backend/SlideGenerator.slnx @@ -1,8 +1,8 @@ + - - - - - + + + + diff --git a/backend/construction.md b/backend/construction.md new file mode 100644 index 00000000..ddd55dd6 --- /dev/null +++ b/backend/construction.md @@ -0,0 +1,58 @@ +Bạn là coding agent làm việc trên backend hiện tại của SlideGenerator. +Mục tiêu là **mở rộng/chỉnh sửa an toàn** theo kiến trúc đang có, không rewrite toàn bộ solution. + +## 1) Solution hiện tại (source of truth) +Các project backend đang dùng: +- `SlideGenerator.Configs`: config entities/contracts/services (`ConfigManager`, `IConfigProvider`) +- `SlideGenerator.Framework`: thư viện core logic dùng lại (slide/sheet/image/cloud services) +- `SlideGenerator.Scanning`: scan PowerPoint/Excel metadata +- `SlideGenerator.Generating`: xử lý generate row-level (text/image/ROI/download) +- `SlideGenerator.Jobs`: orchestration job + SQLite persistence + pause/resume/cancel/recovery +- `SlideGenerator.Ipc`: stdio JSON-RPC host + endpoint layer + +## 2) Kiến trúc và nguyên tắc bắt buộc +- Ưu tiên tái sử dụng logic đã có trong `Framework` (không duplicate nếu đã có service phù hợp). +- `Ipc` chỉ là adapter transport (JSON-RPC), không chứa business logic nặng. +- `Jobs` orchestrate workflow; `Generating` xử lý generation runtime; `Scanning` xử lý metadata scan. +- Không đưa logic IO/domain vào sai layer. + +## 3) Config & DI +- Chỉ `Program.cs` của IPC tạo singleton `ConfigManager`. +- Mapping config read-only hiện dùng `IConfigProvider` từ `ConfigManager`. +- Không inject trực tiếp model `Config` làm dependency root trừ trường hợp read snapshot trong runtime service. +- Dùng `Microsoft.Extensions.DependencyInjection`, tránh wire service thủ công bằng `new` trong constructors. + +## 4) Face detection contract (đang áp dụng) +- Model lifecycle do `FaceDetectorModelManager` ở `Generating` quản lý. +- `Framework` (`YuNetModel`/`FaceDetectorModel`) không auto-init trong `DetectAsync`. +- Nếu detect khi model chưa init: throw exception. +- `Framework` trả toàn bộ detections; lọc score ở caller/business layer. + +## 5) Download contract (đang áp dụng) +- Download ảnh remote trong generate pipeline phải đi qua `DownloadService` (thư viện `Downloader`). +- Không thêm logic tải remote ad-hoc bằng `HttpClient` trong pipeline generate. + +## 6) JSON-RPC endpoint scope (hiện có) +- `system.*` (health) +- `slide.*` / `sheet.*` (scan) +- `jobs.*` (create/get/list/pause/resume/cancel + notifications) +- `configs.*` (get/reload/save/reset) + +Endpoint organization hiện tại: +- Endpoint chia file partial trong `SlideGenerator.Ipc/Endpoints`. +- Request DTO đặt trong `SlideGenerator.Ipc/Contracts/Requests`. +- Endpoint chỉ validate input và gọi `BackendService`. + +## 7) Scanning contract +- `ScanService` phải ưu tiên gọi service trong `Framework` (`PresentationDocumentService`, `WorkbookService`, `WorksheetService`, `ShapeService`) thay vì tự parse trùng lặp. +- Kết quả scan trả model mỏng trong `SlideGenerator.Scanning.Models`. + +## 8) Coding style +- C# hiện đại, rõ nghĩa, thread-safe. +- Public API có XML docs trong file chạm tới. +- Không đổi tên/structure lớn nếu không cần. +- Sửa đúng phạm vi yêu cầu, tránh kéo theo refactor ngoài lề. + +## 9) Validation +- Sau thay đổi, luôn build project bị ảnh hưởng trước, rồi build toàn backend nếu khả thi. +- Nếu có lỗi nền không liên quan (pre-existing), nêu rõ và tách khỏi phần thay đổi mới. diff --git a/backend/docs/en/architecture.md b/backend/docs/en/architecture.md index 6027e2e0..d97e5471 100644 --- a/backend/docs/en/architecture.md +++ b/backend/docs/en/architecture.md @@ -4,65 +4,53 @@ ## Overview -The backend is built on the principles of **Clean Architecture**, ensuring a strict separation of concerns. This design allows the core business logic to remain independent of frameworks, databases, and external interfaces. +The backend is organized in a **feature-based** style for simpler ownership and faster iteration. +Instead of enforcing strict clean-architecture rings, each project owns one runtime feature while reusing shared contracts from `SlideGenerator.Application` and domain models from `SlideGenerator.Domain`. -## Layered Architecture - -The solution is divided into four concentric layers: +## Project Layout ```mermaid graph TD - Presentation --> Application - Application --> Domain - Infrastructure --> Application - Infrastructure --> Domain - Presentation --> Infrastructure + Ipc[SlideGenerator.Ipc] --> JobRuntime[SlideGenerator.Jobs] + JobRuntime --> Scan[SlideGenerator.Scan] + JobRuntime --> Generate[SlideGenerator.Generate] + JobRuntime --> App[SlideGenerator.Application] + Scan --> App + Generate --> App + JobRuntime --> Domain[SlideGenerator.Domain] + Scan --> Framework[SlideGenerator.Framework] + JobRuntime --> Framework ``` -### 1. Domain Layer (`SlideGenerator.Domain`) -**The Core.** Contains the enterprise business rules and entities. -- **Dependencies:** None. -- **Components:** - - `Entities`: Core objects like `JobGroup`, `JobSheet`. - - `Enums`: `JobStatus`, `JobType`. - - `ValueObjects`: Immutable descriptors. - - `Constants`: System-wide invariants. +### `SlideGenerator.Ipc` +- JSON-RPC host over stdio (`StreamJsonRpc`). +- Exposes methods: `system.health`, `slides.scan`, `excel.scan`, `jobs.*`. +- Emits `jobs.updated` notifications. -### 2. Application Layer (`SlideGenerator.Application`) -**The Orchestrator.** Contains application-specific business rules. -- **Dependencies:** Domain. -- **Components:** - - `Interfaces`: Contracts for Infrastructure (e.g., `IJobStore`, `IFileService`). - - `DTOs`: Data Transfer Objects for API communication. - - `Services`: Business logic services (e.g., `JobManager`). - - `Features`: CQRS-style handlers (if applicable). +### `SlideGenerator.Scan` +- Scan workflows for PPTX and Excel metadata. +- Returns DTO-compatible payloads (`SlideScanResult`, `SheetScanResult`). -### 3. Infrastructure Layer (`SlideGenerator.Infrastructure`) -**The Adapter.** Implements interfaces defined in the Application layer. -- **Dependencies:** Application, Domain. -- **Components:** - - `Hangfire`: Background job processing and state persistence. - - `SQLite`: Physical data storage implementation. - - `FileSystem`: IO operations (reading/writing files). - - `Logging`: Serilog integration. +### `SlideGenerator.Generate` +- Generate request validation and mapping logic. +- Encapsulates feature-specific generation helpers. -### 4. Presentation Layer (`SlideGenerator.Presentation`) -**The Entry Point.** The interface through which users interact with the system. -- **Dependencies:** Application, Infrastructure. -- **Components:** - - `ASP.NET Core`: Web Host configuration. - - `SignalR Hubs`: Real-time API endpoints (`JobHub`, `ConfigHub`). - - `Program.cs`: Dependency Injection (DI) composition root. +### `SlideGenerator.Jobs` +- Main runtime orchestration for create/list/get/control jobs. +- Queue + concurrency control + pause/resume/cancel. +- SQLite persistence for job/sheet/row state and recovery. -## Key Runtime Components +### Shared Projects +- `SlideGenerator.Application`: DTO/contracts and backend service interface. +- `SlideGenerator.Domain`: job snapshots/status models. +- `SlideGenerator.Framework`: low-level slide/image capabilities (unchanged by backend rewrite). -### Job Execution Flow +## Runtime Flow -1. **Request:** `TaskHub` receives a `JobCreate` request (JSON) from the client. -2. **Orchestration:** `JobManager` (Application) validates the request and creates a `JobGroup` (Domain). -3. **Persistence:** `ActiveJobCollection` delegates to `HangfireJobStateStore` (Infrastructure) to save the initial state. -4. **Execution:** `Hangfire` (Infrastructure) picks up the job. -5. **Processing:** `JobExecutor` (Application/Infrastructure) performs the slide generation using the Framework. -6. **Notification:** `JobNotifier` (Infrastructure) pushes updates back to the client via `SignalR`. +1. `Ipc` receives JSON-RPC request. +2. `JobRuntime` validates and persists initial job state to SQLite. +3. Runtime enqueues work with bounded concurrency. +4. Per sheet/row processing executes and checkpoints progress. +5. Runtime publishes `jobs.updated` snapshots back to client. -Next: [SignalR API](signalr.md) +Next: [Stdio JSON-RPC API](stdio-jsonrpc.md) diff --git a/backend/docs/en/deployment.md b/backend/docs/en/deployment.md index 31c938ff..d039e40f 100644 --- a/backend/docs/en/deployment.md +++ b/backend/docs/en/deployment.md @@ -4,7 +4,7 @@ Vietnamese version: [Vietnamese](../vi/deployment.md) ## Summary -The backend is an ASP.NET Core app hosted by `SlideGenerator.Presentation`. +The backend is an ASP.NET Core app hosted by `SlideGenerator.Ipc`. ## Steps diff --git a/backend/docs/en/development.md b/backend/docs/en/development.md index 0afd501b..095bfcb6 100644 --- a/backend/docs/en/development.md +++ b/backend/docs/en/development.md @@ -7,21 +7,22 @@ Vietnamese version: [Vietnamese](../vi/development.md) From `backend/`: - Build: `dotnet build` -- Run: `dotnet run --project src/SlideGenerator.Presentation` +- Run: `dotnet run --project src/SlideGenerator.Ipc` ## Code structure Feature-based slices live across layers: -- Presentation: `src/SlideGenerator.Presentation/Features/*/*Hub.cs` +- Presentation: `src/SlideGenerator.Ipc/Features/*/*Hub.cs` +- Presentation: `src/SlideGenerator.Ipc/Features/JsonRpc/*` - Application: `src/SlideGenerator.Application/Features/*` - Domain: `src/SlideGenerator.Domain/Features/*` - Infrastructure: `src/SlideGenerator.Infrastructure/Features/*` ## Key entry points -- `SlideGenerator.Presentation/Program.cs`: host setup and DI wiring. -- `Presentation/Features/Tasks/TaskHub.cs`: task API entry. +- `SlideGenerator.Ipc/Program.cs`: host setup and DI wiring. +- `SlideGenerator.Ipc/Features/JsonRpc/Categories/RpcEndpoint*.cs`: JSON-RPC API entry points. - `Infrastructure/Features/Jobs`: Hangfire executor, state store, collections. ## Testing diff --git a/backend/docs/en/job-system.md b/backend/docs/en/job-system.md index 5246e729..24485fea 100644 --- a/backend/docs/en/job-system.md +++ b/backend/docs/en/job-system.md @@ -2,75 +2,75 @@ [🇻🇳 Vietnamese Version](../vi/job-system.md) -The Job System is the core engine of SlideGenerator, responsible for managing the lifecycle of slide generation tasks. It supports complex workflows including grouping, pausing, resuming, and crash recovery. +The Job System is the core engine of SlideGenerator, responsible for managing the lifecycle of slide generation tasks. It supports grouping, pause/resume/cancel control, and crash recovery from SQLite checkpoints. ## Concepts ### Job Hierarchy -The system uses a composite pattern to manage jobs: +The runtime executes a 3-level hierarchy: -1. **Group Job (`JobGroup`)**: The root container. Represents a single user request (one Workbook + one Template). - * Contains multiple **Sheet Jobs**. - * Manages shared resources (template parsing, output folder). -2. **Sheet Job (`JobSheet`)**: The atomic unit of work. Represents the generation of one output file from one worksheet. +1. **Book Job** + - Root request scope. + - Validates config/input and prepares output files. +2. **Sheet Job** + - One worksheet mapped to one output presentation. + - Expands template slide by row count. +3. **Row Job** + - One record processing unit. + - Resolves text/image replacement with column priority. ### Job States A job transitions through the following states: - **Pending:** Queued and waiting for execution resources. -- **Processing:** Currently running (parsing data or generating slides). +- **Running:** Currently executing. - **Paused:** Temporarily stopped by the user. State is preserved. -- **Done:** Successfully completed. +- **Completed:** Successfully completed. - **Cancelled:** Stopped by user request. -- **Error:** Failed due to an exception. +- **Failed:** Failed due to an exception. ### State Diagram ```mermaid stateDiagram-v2 [*] --> Pending - Pending --> Processing: Scheduler picks up - Processing --> Paused: User Pause - Paused --> Processing: User Resume - Processing --> Done: Success - Processing --> Error: Exception - Processing --> Cancelled: User Cancel + Pending --> Running: Scheduler picks up + Running --> Paused: User Pause + Paused --> Running: User Resume + Running --> Completed: Success + Running --> Failed: Exception + Running --> Cancelled: User Cancel Paused --> Cancelled: User Cancel Pending --> Cancelled: User Cancel ``` -## Collections & Persistence +## Persistence & Recovery -The `JobManager` orchestrates jobs across two primary collections: +Job state is persisted in SQLite: -1. **Active Collection:** - * **Storage:** In-memory `ConcurrentDictionary`. - * **Contents:** Jobs that are `Pending`, `Processing`, or `Paused`. - * **Persistence:** State is continuously synced to SQLite via `HangfireJobStateStore`. -2. **Completed Collection:** - * **Storage:** In-memory (cached) + SQLite (archived). - * **Contents:** Jobs that are `Done`, `Failed`, or `Cancelled`. +- `jobs`: root job status and serialized request payload. +- `job_sheets`: per-sheet checkpoint (`current_row`, `total_rows`, status, output path). +- `job_rows`: per-row status and idempotency key. ### Crash Recovery The system is designed to be resilient. -- **State Saving:** Every state change and progress update is written to the local SQLite database. -- **Recovery:** On application restart, the system loads unfinished jobs from the database. - - Jobs that were `Processing` are demoted to `Paused` to prevent immediate resource contention. - - `Pending` jobs remain `Pending`. +- **State Saving:** status/progress updates are flushed during execution. +- **Recovery:** on startup, pending/running jobs are re-enqueued. +- **Idempotency:** row-level idempotency keys provide best-effort exactly-once behavior. ## Workflow ### 1. Creation (`JobCreate`) -- User submits a request via SignalR. -- System creates a `JobGroup` and analyzes the Excel workbook to create child `JobSheet`s. -- The Group is added to the **Active Collection**. +- User submits a request via JSON-RPC (`jobs.create`). +- Runtime validates template/data paths and writes initial job/sheet rows. +- Job enters `Pending` and is pushed to runtime queue. ### 2. Execution -- If `AutoStart` is enabled, jobs are enqueued to Hangfire. -- **Concurrency Control:** The system respects `job.maxConcurrentJobs` configuration to limit parallel processing. -- **Resume Strategy:** When resuming, the system prioritizes filling available slots with paused jobs before starting new pending ones. +- Runtime worker picks queued jobs and executes `Book -> Sheet -> Row`. +- **Concurrency Control:** bounded by configured semaphores (book and sheet level). +- **Resume Strategy:** paused jobs return to queue on `jobs.resume`. ### 3. Processing - **Step 1:** Load Template & Data. @@ -79,12 +79,13 @@ The system is designed to be resilient. - **Step 4:** Save to Output Path. ### 4. Completion -- When a `JobSheet` finishes, it updates its status. -- When **all** `JobSheet`s in a `JobGroup` are finished, the Group transitions to `Completed` and is moved to the **Completed Collection**. +- Each finished row updates checkpoint and progress. +- A sheet becomes `Completed` when all rows are done. +- A book becomes `Completed` when all mapped sheets are done. ## Concurrency Model - **Limit:** Defined by `job.maxConcurrentJobs` in `backend.config.yaml`. -- **Scope:** Limits the number of *Sheet Jobs* running simultaneously, not Groups. A single Group with 10 sheets can consume all available slots. +- **Scope:** limits top-level book execution; sheet concurrency is additionally bounded inside a book. -Next: [SignalR API](signalr.md) +Next: [Stdio JSON-RPC API](stdio-jsonrpc.md) diff --git a/backend/docs/en/signalr.md b/backend/docs/en/signalr.md deleted file mode 100644 index 0e8d0eef..00000000 --- a/backend/docs/en/signalr.md +++ /dev/null @@ -1,125 +0,0 @@ -# SignalR API - -[🇻🇳 Vietnamese Version](../vi/signalr.md) - -The backend exposes a real-time API via SignalR hubs. All communication follows a request/response pattern with asynchronous notifications. - -## Hub Endpoints - -| Endpoint | Description | -| :--- | :--- | -| `/hubs/job` | Main endpoint for creating, controlling, and querying jobs. | -| `/hubs/sheet` | Utilities for inspecting Excel workbooks (headers, rows). | -| `/hubs/config` | Read and write backend configuration. | - -> **Note:** `/hubs/task` is a legacy alias for `/hubs/job`. - -## Protocol - -### Request Pattern -Clients send requests by invoking the `ProcessRequest` method on the Hub with a JSON payload. - -- **Required Field:** `type` (case-insensitive string). -- **Response:** Sent back via the `ReceiveResponse` event. -- **Errors:** Returned as a message with type `error`. - -## Job Hub Messages (`/hubs/job`) - -### 1. Create Job (`JobCreate`) - -Creates a new generation task. - -**Group Job (Workbook + Template):** -```json -{ - "type": "JobCreate", - "jobType": "Group", - "templatePath": "C:\\slides\\template.pptx", - "spreadsheetPath": "C:\\data\\book.xlsx", - "outputPath": "C:\\output", - "sheetNames": ["Sheet1", "Sheet2"], - "textConfigs": [ - { "pattern": "{{Name}}", "columns": ["FullName"] } - ], - "imageConfigs": [ - { - "shapeId": 4, - "columns": ["Photo"], - "roiType": "RuleOfThirds", - "cropType": "Fit" - } - ], - "autoStart": true -} -``` - -**Sheet Job (Single Sheet):** -```json -{ - "type": "JobCreate", - "jobType": "Sheet", - "templatePath": "C:\\slides\\template.pptx", - "spreadsheetPath": "C:\\data\\book.xlsx", - "outputPath": "C:\\output\\Sheet1.pptx", - "sheetName": "Sheet1" -} -``` - -### 2. Control Job (`JobControl`) - -Manage the state of running jobs. - -- **Actions:** `Pause`, `Resume`, `Cancel`, `Stop` (same as Cancel), `Remove` (delete from history). - -```json -{ - "type": "JobControl", - "jobId": "GUID-ID-HERE", - "jobType": "Group", - "action": "Pause" -} -``` - -### 3. Query Job (`JobQuery`) - -Retrieve job details. - -- **Scope:** `Active`, `Completed`, `All`. -- **includePayload:** Returns the original JSON payload (reconstructed from DB). - -```json -{ - "type": "JobQuery", - "jobId": "GUID-ID-HERE", - "jobType": "Group", - "includeSheets": true -} -``` - -### 4. Scan Template -Helpers to inspect PPTX files. -- **Actions:** `ScanShapes`, `ScanPlaceholders`, `ScanTemplate`. - -```json -{ - "type": "ScanShapes", - "filePath": "C:\\slides\\template.pptx" -} -``` - -## Notifications - -Clients must listen to `ReceiveNotification` to get real-time updates. - -**Event Types:** -- `GroupProgress`: Overall progress of a group. -- `SheetProgress`: Progress of an individual sheet. -- `JobStatus`: State changes (e.g., Pending -> Processing). -- `LogEvent`: Structured log messages from the backend. - -## Subscriptions - -To receive detailed updates for specific jobs, clients must subscribe: - -- `SubscribeGroup(groupId)` -- `SubscribeSheet(sheetId)` diff --git a/backend/docs/en/stdio-jsonrpc.md b/backend/docs/en/stdio-jsonrpc.md new file mode 100644 index 00000000..9ec81a63 --- /dev/null +++ b/backend/docs/en/stdio-jsonrpc.md @@ -0,0 +1,88 @@ +# Stdio JSON-RPC Backend + +This backend host runs as a line-delimited JSON-RPC 2.0 server over standard input/output. + +- Requests: `stdin` (one JSON document per line) +- Responses + notifications: `stdout` +- Diagnostics/errors: `stderr` + +## Methods + +### `system.health` +Checks whether server loop is alive. + +Request: +```json +{"jsonrpc":"2.0","id":1,"method":"system.health","params":{}} +``` + +### `slides.scan` +Scans a PPTX and returns per-slide image shape ids and mustache placeholders. + +Params: +```json +{"filePath":"C:/path/to/template.pptx"} +``` + +### `excel.scan` +Scans an Excel workbook and returns sheet headers + data row counts. + +Params: +```json +{"filePath":"C:/path/to/data.xlsx"} +``` + +### `jobs.create` +Creates a generation job, persists state in SQLite, and enqueues background processing. + +Params shape: +- `templates`: `[{ templateKey, filePath, templateSlideIndex }]` +- `sheetPath`: workbook path +- `sheetTemplateMap`: object map `sheetName -> templateKey` +- `selectedSheets`: optional array; if null/empty, all mapped sheets are used +- `textConfig`: `[{ placeholder, columns[] }]` +- `imageConfig`: `[{ shapeId, columns[], roiMode }]` +- `outputFolder`: output directory + +### `jobs.get` +Gets current snapshot for a job. + +Params: +```json +{"jobId":""} +``` + +### `jobs.list` +Lists all jobs. + +### `jobs.pause` / `jobs.resume` / `jobs.cancel` +Controls a job lifecycle. + +Params: +```json +{"jobId":""} +``` + +## Notifications + +### `jobs.updated` +Emitted whenever status/progress/checkpoint changes. + +Payload is a full `JobSnapshot` object. + +## Persistence + +- SQLite file: `Jobs.db` (default path from `Config.DefaultDatabasePath`) +- Tables: + - `jobs` + - `job_sheets` + - `job_rows` +- Resume behavior: + - Pending/Running jobs are re-enqueued on startup. + - Row-level checkpoints are used for best-effort exactly-once processing. + +## Notes + +- `sheetPath` is the canonical request field for Excel input in backend DTOs. +- `roiMode` accepts: `center`, `prominent`, `ruleofthirds`. +- Runtime logs diagnostics to `stderr`; JSON-RPC payloads are emitted only to `stdout`. diff --git a/backend/docs/en/usage.md b/backend/docs/en/usage.md index 43d77ee5..1bdf0f65 100644 --- a/backend/docs/en/usage.md +++ b/backend/docs/en/usage.md @@ -7,7 +7,7 @@ Vietnamese version: [Vietnamese](../vi/usage.md) From `backend/`: ``` -dotnet run --project src/SlideGenerator.Presentation +dotnet run --project src/SlideGenerator.Ipc ``` ## Verify @@ -17,13 +17,13 @@ dotnet run --project src/SlideGenerator.Presentation ## Connect from the client -- Job hub: `/hubs/job` (alias: `/hubs/task`) -- Sheet hub: `/hubs/sheet` -- Config hub: `/hubs/config` +- Transport: stdio JSON-RPC 2.0 +- Main methods: `jobs.create`, `jobs.get`, `jobs.list`, `jobs.pause`, `jobs.resume`, `jobs.cancel` +- Utility methods: `slides.scan`, `excel.scan`, `system.health` ## Quick examples -Create a group job: +Create a group job (`jobs.create` params): ```json { @@ -36,20 +36,20 @@ Create a group job: } ``` -Pause a job: +Pause a job (`jobs.pause` params): ```json -{ "type": "JobControl", "jobId": "TASK_ID", "jobType": "Group", "action": "Pause" } +{ "jobId": "TASK_ID" } ``` -Remove a group (also deletes backend state): +Cancel a group (`jobs.cancel` params): ```json -{ "type": "JobControl", "jobId": "TASK_ID", "jobType": "Group", "action": "Remove" } +{ "jobId": "TASK_ID" } ``` -Query active jobs: +Get a specific job (`jobs.get` params): ```json -{ "type": "JobQuery", "scope": "Active" } +{ "jobId": "TASK_ID" } ``` diff --git a/backend/docs/vi/architecture.md b/backend/docs/vi/architecture.md index 8e443bb6..599d5784 100644 --- a/backend/docs/vi/architecture.md +++ b/backend/docs/vi/architecture.md @@ -46,23 +46,23 @@ graph TD - `FileSystem`: Các thao tác I/O (đọc/ghi file). - `Logging`: Tích hợp Serilog. -### 4. Tầng Presentation (`SlideGenerator.Presentation`) +### 4. Tầng Presentation (`SlideGenerator.Ipc`) **Điểm nhập.** Giao diện để người dùng tương tác với hệ thống. - **Phụ thuộc:** Application, Infrastructure. - **Thành phần:** - - `ASP.NET Core`: Cấu hình Web Host. - - `SignalR Hubs`: Các endpoint API thời gian thực (`JobHub`, `ConfigHub`). + - `StreamJsonRpc`: transport stdio JSON-RPC. + - `RpcEndpoint`: các nhóm API jobs/slides/excel/system. - `Program.cs`: Root (gốc) để cấu hình Dependency Injection (DI). ## Các thành phần Runtime chính ### Luồng thực thi Job -1. **Yêu cầu:** `TaskHub` nhận một yêu cầu `JobCreate` (JSON) từ client. +1. **Yêu cầu:** `RpcEndpoint` nhận yêu cầu JSON-RPC `jobs.create` từ client. 2. **Điều phối:** `JobManager` (Application) xác thực yêu cầu và tạo một `JobGroup` (Domain). 3. **Lưu trữ:** `ActiveJobCollection` ủy quyền cho `HangfireJobStateStore` (Infrastructure) để lưu trạng thái ban đầu. 4. **Thực thi:** `Hangfire` (Infrastructure) nhận job để xử lý. 5. **Xử lý:** `JobExecutor` (Application/Infrastructure) thực hiện việc tạo slide sử dụng Framework. -6. **Thông báo:** `JobNotifier` (Infrastructure) đẩy cập nhật trạng thái về client thông qua `SignalR`. +6. **Thông báo:** Backend phát sự kiện `jobs.updated` qua JSON-RPC. -Tiếp theo: [SignalR API](signalr.md) +Tiếp theo: [Stdio JSON-RPC API](../en/stdio-jsonrpc.md) diff --git a/backend/docs/vi/deployment.md b/backend/docs/vi/deployment.md index e0ef8952..d0a36f73 100644 --- a/backend/docs/vi/deployment.md +++ b/backend/docs/vi/deployment.md @@ -4,7 +4,7 @@ English version: [English](../en/deployment.md) ## Tóm tắt -Backend là ứng dụng ASP.NET Core chạy từ `SlideGenerator.Presentation`. +Backend là ứng dụng ASP.NET Core chạy từ `SlideGenerator.Ipc`. ## Các bước diff --git a/backend/docs/vi/development.md b/backend/docs/vi/development.md index c381d085..26255c14 100644 --- a/backend/docs/vi/development.md +++ b/backend/docs/vi/development.md @@ -7,21 +7,21 @@ English version: [English](../en/development.md) Từ thư mục `backend/`: - Build: `dotnet build` -- Run: `dotnet run --project src/SlideGenerator.Presentation` +- Run: `dotnet run --project src/SlideGenerator.Ipc` ## Cấu trúc code Code chia theo feature ở các layer: -- Presentation: `src/SlideGenerator.Presentation/Features/*/*Hub.cs` +- Presentation: `src/SlideGenerator.Ipc/Features/JsonRpc/*` - Application: `src/SlideGenerator.Application/Features/*` - Domain: `src/SlideGenerator.Domain/Features/*` - Infrastructure: `src/SlideGenerator.Infrastructure/Features/*` ## Điểm vào chính -- `SlideGenerator.Presentation/Program.cs`: host và DI. -- `Presentation/Features/Tasks/TaskHub.cs`: API task. +- `SlideGenerator.Ipc/Program.cs`: host và DI. +- `SlideGenerator.Ipc/Features/JsonRpc/Categories/RpcEndpoint*.cs`: entry point JSON-RPC API. - `Infrastructure/Features/Jobs`: executor, state store, collections. ## Testing diff --git a/backend/docs/vi/job-system.md b/backend/docs/vi/job-system.md index ff0da062..89c309e4 100644 --- a/backend/docs/vi/job-system.md +++ b/backend/docs/vi/job-system.md @@ -63,7 +63,7 @@ Hệ thống được thiết kế để có khả năng phục hồi cao. ## Quy trình làm việc (Workflow) ### 1. Khởi tạo (`JobCreate`) -- Người dùng gửi yêu cầu qua SignalR. +- Người dùng gửi yêu cầu qua JSON-RPC (`jobs.create`). - Hệ thống tạo `JobGroup` và phân tích Excel workbook để tạo các `JobSheet` con. - Group được thêm vào **Active Collection**. @@ -87,5 +87,5 @@ Hệ thống được thiết kế để có khả năng phục hồi cao. - **Giới hạn:** Được định nghĩa bởi `job.maxConcurrentJobs` trong `backend.config.yaml`. - **Phạm vi:** Giới hạn số lượng *Sheet Jobs* chạy đồng thời, không phải Groups. Một Group đơn lẻ với 10 sheet có thể chiếm dụng toàn bộ các slot xử lý. -Tiếp theo: [SignalR API](signalr.md) +Tiếp theo: [Stdio JSON-RPC API](../en/stdio-jsonrpc.md) diff --git a/backend/docs/vi/signalr.md b/backend/docs/vi/signalr.md deleted file mode 100644 index ae3cd7af..00000000 --- a/backend/docs/vi/signalr.md +++ /dev/null @@ -1,126 +0,0 @@ -# SignalR API - -[🇺🇸 English Version](../en/signalr.md) - -Backend cung cấp một API thời gian thực thông qua SignalR hubs. Mọi giao tiếp đều tuân theo mẫu request/response kèm theo các thông báo (notification) bất đồng bộ. - -## Các Hub Endpoint - -| Endpoint | Mô tả | -| :--- | :--- | -| `/hubs/job` | Endpoint chính để tạo, điều khiển và truy vấn job. | -| `/hubs/sheet` | Tiện ích để kiểm tra Excel workbook (tiêu đề, dòng dữ liệu). | -| `/hubs/config` | Đọc và ghi cấu hình backend. | - -> **Lưu ý:** `/hubs/task` là alias cũ (legacy) của `/hubs/job`. - -## Giao thức - -### Mẫu Request -Client gửi yêu cầu bằng cách gọi phương thức `ProcessRequest` trên Hub với payload JSON. - -- **Trường bắt buộc:** `type` (chuỗi ký tự, không phân biệt hoa thường). -- **Phản hồi:** Được gửi lại qua sự kiện `ReceiveResponse`. -- **Lỗi:** Trả về message với type là `error`. - -## Job Hub Messages (`/hubs/job`) - -### 1. Tạo Job (`JobCreate`) - -Tạo một tác vụ tạo slide mới. - -**Group Job (Workbook + Template):** -```json -{ - "type": "JobCreate", - "jobType": "Group", - "templatePath": "C:\\slides\\template.pptx", - "spreadsheetPath": "C:\\data\\book.xlsx", - "outputPath": "C:\\output", - "sheetNames": ["Sheet1", "Sheet2"], - "textConfigs": [ - { "pattern": "{{Name}}", "columns": ["FullName"] } - ], - "imageConfigs": [ - { - "shapeId": 4, - "columns": ["Photo"], - "roiType": "RuleOfThirds", - "cropType": "Fit" - } - ], - "autoStart": true -} -``` - -**Sheet Job (Single Sheet):** -```json -{ - "type": "JobCreate", - "jobType": "Sheet", - "templatePath": "C:\\slides\\template.pptx", - "spreadsheetPath": "C:\\data\\book.xlsx", - "outputPath": "C:\\output\\Sheet1.pptx", - "sheetName": "Sheet1" -} -``` - -### 2. Điều khiển Job (`JobControl`) - -Quản lý trạng thái của các job đang chạy. - -- **Hành động:** `Pause`, `Resume`, `Cancel`, `Stop` (giống Cancel), `Remove` (xóa khỏi lịch sử). - -```json -{ - "type": "JobControl", - "jobId": "GUID-ID-HERE", - "jobType": "Group", - "action": "Pause" -} -``` - -### 3. Truy vấn Job (`JobQuery`) - -Lấy chi tiết job. - -- **Phạm vi (Scope):** `Active`, `Completed`, `All`. -- **includePayload:** Trả về JSON payload gốc (được tái tạo từ DB). - -```json -{ - "type": "JobQuery", - "jobId": "GUID-ID-HERE", - "jobType": "Group", - "includeSheets": true -} -``` - -### 4. Quét Template (Scan Template) -Các tiện ích để kiểm tra file PPTX. -- **Hành động:** `ScanShapes`, `ScanPlaceholders`, `ScanTemplate`. - -```json -{ - "type": "ScanShapes", - "filePath": "C:\\slides\\template.pptx" -} -``` - -## Thông báo (Notifications) - -Client phải lắng nghe sự kiện `ReceiveNotification` để nhận cập nhật thời gian thực. - -**Loại sự kiện:** -- `GroupProgress`: Tiến độ tổng thể của một group. -- `SheetProgress`: Tiến độ của một sheet đơn lẻ. -- `JobStatus`: Thay đổi trạng thái (ví dụ: Pending -> Processing). -- `LogEvent`: Log message có cấu trúc từ backend. - -## Đăng ký (Subscriptions) - -Để nhận cập nhật chi tiết cho các job cụ thể, client cần đăng ký: - -- `SubscribeGroup(groupId)` -- `SubscribeSheet(sheetId)` - diff --git a/backend/docs/vi/usage.md b/backend/docs/vi/usage.md index 8f6cf89e..0ab81c33 100644 --- a/backend/docs/vi/usage.md +++ b/backend/docs/vi/usage.md @@ -7,7 +7,7 @@ English version: [English](../en/usage.md) Từ thư mục `backend/`: ``` -dotnet run --project src/SlideGenerator.Presentation +dotnet run --project src/SlideGenerator.Ipc ``` ## Kiểm tra @@ -17,13 +17,13 @@ dotnet run --project src/SlideGenerator.Presentation ## Kết nối từ client -- Job hub: `/hubs/job` (alias: `/hubs/task`) -- Sheet hub: `/hubs/sheet` -- Config hub: `/hubs/config` +- Transport: stdio JSON-RPC 2.0 +- Method chính: `jobs.create`, `jobs.get`, `jobs.list`, `jobs.pause`, `jobs.resume`, `jobs.cancel` +- Method tiện ích: `slides.scan`, `excel.scan`, `system.health` ## Ví dụ nhanh -Tạo group job: +Tạo group job (`jobs.create` params): ```json { @@ -36,21 +36,21 @@ Tạo group job: } ``` -Tạm dừng job: +Tạm dừng job (`jobs.pause` params): ```json -{ "type": "JobControl", "jobId": "TASK_ID", "jobType": "Group", "action": "Pause" } +{ "jobId": "TASK_ID" } ``` -Xóa group (xóa cả backend state): +Hủy group (`jobs.cancel` params): ```json -{ "type": "JobControl", "jobId": "TASK_ID", "jobType": "Group", "action": "Remove" } +{ "jobId": "TASK_ID" } ``` -Query job đang chạy: +Lấy chi tiết job (`jobs.get` params): ```json -{ "type": "JobQuery", "scope": "Active" } +{ "jobId": "TASK_ID" } ``` diff --git a/backend/src/SlideGenerator.Application/Common/Base/DTOs/Responses/Response.cs b/backend/src/SlideGenerator.Application/Common/Base/DTOs/Responses/Response.cs deleted file mode 100644 index ece5c4f7..00000000 --- a/backend/src/SlideGenerator.Application/Common/Base/DTOs/Responses/Response.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace SlideGenerator.Application.Common.Base.DTOs.Responses; - -/// -/// Base response type for SignalR APIs. -/// -public abstract record Response(string Type); \ No newline at end of file diff --git a/backend/src/SlideGenerator.Application/Common/Utilities/OutputPathUtils.cs b/backend/src/SlideGenerator.Application/Common/Utilities/OutputPathUtils.cs deleted file mode 100644 index 9801906c..00000000 --- a/backend/src/SlideGenerator.Application/Common/Utilities/OutputPathUtils.cs +++ /dev/null @@ -1,24 +0,0 @@ -namespace SlideGenerator.Application.Common.Utilities; - -/// -/// Provides helpers for normalizing output paths for slide generation. -/// -public static class OutputPathUtils -{ - /// - /// Normalizes output path to a directory (accepts .pptx file path or folder path). - /// - public static string NormalizeOutputFolderPath(string outputPath) - { - var fullPath = Path.GetFullPath(outputPath); - if (Path.HasExtension(fullPath) && - string.Equals(Path.GetExtension(fullPath), ".pptx", StringComparison.OrdinalIgnoreCase)) - { - var directory = Path.GetDirectoryName(fullPath); - if (!string.IsNullOrWhiteSpace(directory)) - return directory; - } - - return fullPath; - } -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Application/Features/Configs/ConfigHolder.cs b/backend/src/SlideGenerator.Application/Features/Configs/ConfigHolder.cs deleted file mode 100644 index eb44555a..00000000 --- a/backend/src/SlideGenerator.Application/Features/Configs/ConfigHolder.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System.Runtime.CompilerServices; -using SlideGenerator.Domain.Configs; - -[assembly: InternalsVisibleTo("SlideGenerator.Presentation")] - -namespace SlideGenerator.Application.Features.Configs; - -public static class ConfigHolder -{ - internal static readonly Lock Locker = new(); - public static Config Value { get; internal set; } = new(); - - /// - /// Resets the configuration to its default state by reinitializing the singleton instance. - /// - /// - /// Call this method to discard any changes made to the current configuration and restore the - /// default settings. This method is thread-safe. - /// - public static void Reset() - { - lock (Locker) - { - Value = new Config(); - } - } -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Application/Features/Configs/DTOs/Components/DownloadConfig.cs b/backend/src/SlideGenerator.Application/Features/Configs/DTOs/Components/DownloadConfig.cs deleted file mode 100644 index 86e7cb6a..00000000 --- a/backend/src/SlideGenerator.Application/Features/Configs/DTOs/Components/DownloadConfig.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace SlideGenerator.Application.Features.Configs.DTOs.Components; - -/// -/// Download configuration DTO. -/// -public sealed record DownloadConfig( - int MaxChunks, - int LimitBytesPerSecond, - string SaveFolder, - RetryConfig Retry); \ No newline at end of file diff --git a/backend/src/SlideGenerator.Application/Features/Configs/DTOs/Components/ImageConfig.cs b/backend/src/SlideGenerator.Application/Features/Configs/DTOs/Components/ImageConfig.cs deleted file mode 100644 index ad3578af..00000000 --- a/backend/src/SlideGenerator.Application/Features/Configs/DTOs/Components/ImageConfig.cs +++ /dev/null @@ -1,24 +0,0 @@ -namespace SlideGenerator.Application.Features.Configs.DTOs.Components; - -/// -/// Image configuration DTO. -/// -public sealed record ImageConfig( - FaceConfig Face, - SaliencyConfig Saliency); - -/// -/// Face detection configuration DTO. -/// -public sealed record FaceConfig( - float Confidence, - bool UnionAll); - -/// -/// Saliency configuration DTO. -/// -public sealed record SaliencyConfig( - float PaddingTop, - float PaddingBottom, - float PaddingLeft, - float PaddingRight); \ No newline at end of file diff --git a/backend/src/SlideGenerator.Application/Features/Configs/DTOs/Components/JobConfig.cs b/backend/src/SlideGenerator.Application/Features/Configs/DTOs/Components/JobConfig.cs deleted file mode 100644 index 584d9531..00000000 --- a/backend/src/SlideGenerator.Application/Features/Configs/DTOs/Components/JobConfig.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace SlideGenerator.Application.Features.Configs.DTOs.Components; - -/// -/// Job configuration DTO. -/// -public sealed record JobConfig(int MaxConcurrentJobs); \ No newline at end of file diff --git a/backend/src/SlideGenerator.Application/Features/Configs/DTOs/Components/RetryConfig.cs b/backend/src/SlideGenerator.Application/Features/Configs/DTOs/Components/RetryConfig.cs deleted file mode 100644 index 7ed28217..00000000 --- a/backend/src/SlideGenerator.Application/Features/Configs/DTOs/Components/RetryConfig.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace SlideGenerator.Application.Features.Configs.DTOs.Components; - -/// -/// Download retry configuration DTO. -/// -public sealed record RetryConfig(int Timeout, int MaxRetries); \ No newline at end of file diff --git a/backend/src/SlideGenerator.Application/Features/Configs/DTOs/Components/ServerConfig.cs b/backend/src/SlideGenerator.Application/Features/Configs/DTOs/Components/ServerConfig.cs deleted file mode 100644 index 20806ec0..00000000 --- a/backend/src/SlideGenerator.Application/Features/Configs/DTOs/Components/ServerConfig.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace SlideGenerator.Application.Features.Configs.DTOs.Components; - -/// -/// Server configuration DTO. -/// -public sealed record ServerConfig(string Host, int Port, bool Debug); \ No newline at end of file diff --git a/backend/src/SlideGenerator.Application/Features/Configs/DTOs/Requests/ConfigUpdate.cs b/backend/src/SlideGenerator.Application/Features/Configs/DTOs/Requests/ConfigUpdate.cs deleted file mode 100644 index ba43b8d0..00000000 --- a/backend/src/SlideGenerator.Application/Features/Configs/DTOs/Requests/ConfigUpdate.cs +++ /dev/null @@ -1,59 +0,0 @@ -namespace SlideGenerator.Application.Features.Configs.DTOs.Requests; - -/// -/// Request to update configuration. -/// -public sealed record ConfigUpdate( - ServerConfigUpdate? Server, - DownloadConfigUpdate? Download, - JobConfigUpdate? Job, - ImageConfigUpdate? Image); - -/// -/// Server configuration update. -/// -public sealed record ServerConfigUpdate(string Host, int Port, bool Debug); - -/// -/// Download configuration update. -/// -public sealed record DownloadConfigUpdate( - int MaxChunks, - int LimitBytesPerSecond, - string SaveFolder, - RetryConfigUpdate Retry); - -/// -/// Download retry configuration update. -/// -public sealed record RetryConfigUpdate(int Timeout, int MaxRetries); - -/// -/// Job configuration update. -/// -public sealed record JobConfigUpdate(int MaxConcurrentJobs); - -/// -/// Image configuration update. -/// -public sealed record ImageConfigUpdate(FaceConfigUpdate Face, SaliencyConfigUpdate Saliency); - -/// -/// Face configuration update. -/// -public sealed record FaceConfigUpdate( - float Confidence, - float PaddingTop, - float PaddingBottom, - float PaddingLeft, - float PaddingRight, - bool UnionAll); - -/// -/// Saliency configuration update. -/// -public sealed record SaliencyConfigUpdate( - float PaddingTop, - float PaddingBottom, - float PaddingLeft, - float PaddingRight); \ No newline at end of file diff --git a/backend/src/SlideGenerator.Application/Features/Configs/DTOs/Requests/ModelControl.cs b/backend/src/SlideGenerator.Application/Features/Configs/DTOs/Requests/ModelControl.cs deleted file mode 100644 index 9af8eba2..00000000 --- a/backend/src/SlideGenerator.Application/Features/Configs/DTOs/Requests/ModelControl.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace SlideGenerator.Application.Features.Configs.DTOs.Requests; - -/// -/// Request to control a model (init/deinit). -/// -public sealed record ModelControl( - string Model, - string Action); \ No newline at end of file diff --git a/backend/src/SlideGenerator.Application/Features/Configs/DTOs/Responses/Errors/ConfigError.cs b/backend/src/SlideGenerator.Application/Features/Configs/DTOs/Responses/Errors/ConfigError.cs deleted file mode 100644 index ba40ca8a..00000000 --- a/backend/src/SlideGenerator.Application/Features/Configs/DTOs/Responses/Errors/ConfigError.cs +++ /dev/null @@ -1,14 +0,0 @@ -using SlideGenerator.Application.Common.Base.DTOs.Responses; - -namespace SlideGenerator.Application.Features.Configs.DTOs.Responses.Errors; - -/// -/// Error response for configuration operations. -/// -public sealed record ConfigError(string Kind, string Message) : Response("error") -{ - public ConfigError(Exception exception) - : this(exception.GetType().Name, exception.Message) - { - } -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Application/Features/Configs/DTOs/Responses/Successes/ConfigGetSuccess.cs b/backend/src/SlideGenerator.Application/Features/Configs/DTOs/Responses/Successes/ConfigGetSuccess.cs deleted file mode 100644 index 018be7fd..00000000 --- a/backend/src/SlideGenerator.Application/Features/Configs/DTOs/Responses/Successes/ConfigGetSuccess.cs +++ /dev/null @@ -1,14 +0,0 @@ -using SlideGenerator.Application.Common.Base.DTOs.Responses; -using SlideGenerator.Application.Features.Configs.DTOs.Components; - -namespace SlideGenerator.Application.Features.Configs.DTOs.Responses.Successes; - -/// -/// Response containing current configuration. -/// -public sealed record ConfigGetSuccess( - ServerConfig Server, - DownloadConfig Download, - JobConfig Job, - ImageConfig Image) - : Response("get"); \ No newline at end of file diff --git a/backend/src/SlideGenerator.Application/Features/Configs/DTOs/Responses/Successes/ConfigReloadSuccess.cs b/backend/src/SlideGenerator.Application/Features/Configs/DTOs/Responses/Successes/ConfigReloadSuccess.cs deleted file mode 100644 index 2643b4ec..00000000 --- a/backend/src/SlideGenerator.Application/Features/Configs/DTOs/Responses/Successes/ConfigReloadSuccess.cs +++ /dev/null @@ -1,9 +0,0 @@ -using SlideGenerator.Application.Common.Base.DTOs.Responses; - -namespace SlideGenerator.Application.Features.Configs.DTOs.Responses.Successes; - -/// -/// Response for configuration reload. -/// -public sealed record ConfigReloadSuccess(bool Success, string Message) - : Response("reload"); \ No newline at end of file diff --git a/backend/src/SlideGenerator.Application/Features/Configs/DTOs/Responses/Successes/ConfigResetSuccess.cs b/backend/src/SlideGenerator.Application/Features/Configs/DTOs/Responses/Successes/ConfigResetSuccess.cs deleted file mode 100644 index ef2e9c7f..00000000 --- a/backend/src/SlideGenerator.Application/Features/Configs/DTOs/Responses/Successes/ConfigResetSuccess.cs +++ /dev/null @@ -1,9 +0,0 @@ -using SlideGenerator.Application.Common.Base.DTOs.Responses; - -namespace SlideGenerator.Application.Features.Configs.DTOs.Responses.Successes; - -/// -/// Response for configuration reset. -/// -public sealed record ConfigResetSuccess(bool Success, string Message) - : Response("reset"); \ No newline at end of file diff --git a/backend/src/SlideGenerator.Application/Features/Configs/DTOs/Responses/Successes/ConfigUpdateSuccess.cs b/backend/src/SlideGenerator.Application/Features/Configs/DTOs/Responses/Successes/ConfigUpdateSuccess.cs deleted file mode 100644 index fee0a308..00000000 --- a/backend/src/SlideGenerator.Application/Features/Configs/DTOs/Responses/Successes/ConfigUpdateSuccess.cs +++ /dev/null @@ -1,9 +0,0 @@ -using SlideGenerator.Application.Common.Base.DTOs.Responses; - -namespace SlideGenerator.Application.Features.Configs.DTOs.Responses.Successes; - -/// -/// Response for configuration updates. -/// -public sealed record ConfigUpdateSuccess(bool Success, string Message) - : Response("update"); \ No newline at end of file diff --git a/backend/src/SlideGenerator.Application/Features/Configs/DTOs/Responses/Successes/ModelControlSuccess.cs b/backend/src/SlideGenerator.Application/Features/Configs/DTOs/Responses/Successes/ModelControlSuccess.cs deleted file mode 100644 index 145f4e42..00000000 --- a/backend/src/SlideGenerator.Application/Features/Configs/DTOs/Responses/Successes/ModelControlSuccess.cs +++ /dev/null @@ -1,13 +0,0 @@ -using SlideGenerator.Application.Common.Base.DTOs.Responses; - -namespace SlideGenerator.Application.Features.Configs.DTOs.Responses.Successes; - -/// -/// Response for model initialization/deinitialization operations. -/// -public sealed record ModelControlSuccess( - string Model, - string Action, - bool Success, - string? Message = null) - : Response("modelcontrol"); \ No newline at end of file diff --git a/backend/src/SlideGenerator.Application/Features/Configs/DTOs/Responses/Successes/ModelStatusSuccess.cs b/backend/src/SlideGenerator.Application/Features/Configs/DTOs/Responses/Successes/ModelStatusSuccess.cs deleted file mode 100644 index 93498d44..00000000 --- a/backend/src/SlideGenerator.Application/Features/Configs/DTOs/Responses/Successes/ModelStatusSuccess.cs +++ /dev/null @@ -1,10 +0,0 @@ -using SlideGenerator.Application.Common.Base.DTOs.Responses; - -namespace SlideGenerator.Application.Features.Configs.DTOs.Responses.Successes; - -/// -/// Response containing model status information. -/// -public sealed record ModelStatusSuccess( - bool FaceModelAvailable) - : Response("modelstatus"); \ No newline at end of file diff --git a/backend/src/SlideGenerator.Application/Features/Downloads/IDownloadService.cs b/backend/src/SlideGenerator.Application/Features/Downloads/IDownloadService.cs deleted file mode 100644 index f5f9ffc7..00000000 --- a/backend/src/SlideGenerator.Application/Features/Downloads/IDownloadService.cs +++ /dev/null @@ -1,20 +0,0 @@ -using SlideGenerator.Domain.Features.Downloads; - -namespace SlideGenerator.Application.Features.Downloads; - -/// -/// Interface for download service. -/// -public interface IDownloadService -{ - /// Create image download task. - /// The URL to download from. - /// The folder to save the downloaded file. - /// The created download task. - IDownloadTask CreateImageTask(string url, DirectoryInfo saveFolder); - - /// - /// Runs a download task. - /// - public Task DownloadTask(IDownloadTask task); -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Application/Features/Images/IImageService.cs b/backend/src/SlideGenerator.Application/Features/Images/IImageService.cs deleted file mode 100644 index 949053fb..00000000 --- a/backend/src/SlideGenerator.Application/Features/Images/IImageService.cs +++ /dev/null @@ -1,47 +0,0 @@ -using System.Drawing; -using SlideGenerator.Domain.Features.Images.Enums; - -namespace SlideGenerator.Application.Features.Images; - -/// -/// Interface for image processing service. -/// -public interface IImageService -{ - /// - /// Gets a value indicating whether the face detection model is currently available and initialized. - /// - bool IsFaceModelAvailable { get; } - - /// - /// Crops the specified image file to the given size and region of interest using the specified crop type - /// asynchronously. - /// - /// The path to the image file to be cropped. Cannot be null or empty. - /// The target size, in pixels, for the cropped image. - /// The region of interest type that determines which part of the image will be cropped. - /// The cropping method to apply to the image. - /// - /// A task that represents the asynchronous operation. The task result contains a byte array with the cropped image - /// data in the original file's format. - /// - Task CropImageAsync(string filePath, Size size, ImageRoiType roiType, ImageCropType cropType); - - /// - /// Initializes the face detection model asynchronously. - /// - /// - /// A task that represents the asynchronous operation. The task result is if the model - /// was successfully initialized; otherwise, . - /// - Task InitFaceModelAsync(); - - /// - /// Deinitializes the face detection model asynchronously. - /// - /// - /// A task that represents the asynchronous operation. The task result is if the model - /// was successfully deinitialized; otherwise, . - /// - Task DeInitFaceModelAsync(); -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Application/Features/Jobs/Contracts/Collections/IActiveJobCollection.cs b/backend/src/SlideGenerator.Application/Features/Jobs/Contracts/Collections/IActiveJobCollection.cs deleted file mode 100644 index ed0014f8..00000000 --- a/backend/src/SlideGenerator.Application/Features/Jobs/Contracts/Collections/IActiveJobCollection.cs +++ /dev/null @@ -1,100 +0,0 @@ -using SlideGenerator.Application.Features.Jobs.DTOs.Requests; -using SlideGenerator.Domain.Features.Jobs.Interfaces; - -namespace SlideGenerator.Application.Features.Jobs.Contracts.Collections; - -/// -/// Manages active jobs (pending/running/paused). -/// -public interface IActiveJobCollection : IJobCollection -{ - /// - /// Gets a value indicating whether any jobs are active. - /// - bool HasActiveJobs { get; } - - /// - /// Creates a new group job from the request. - /// - IJobGroup CreateGroup(JobCreate request); - - /// - /// Starts all sheet jobs in the group. - /// - void StartGroup(string groupId); - - /// - /// Requests pause for all running sheets in the group. - /// - void PauseGroup(string groupId); - - /// - /// Resumes all paused sheets in the group. - /// - void ResumeGroup(string groupId); - - /// - /// Cancels all active sheets in the group. - /// - void CancelGroup(string groupId); - - /// - /// Cancels and removes a group job and its persisted state. - /// - void CancelAndRemoveGroup(string groupId); - - /// - /// Requests pause for a single sheet. - /// - void PauseSheet(string sheetId); - - /// - /// Resumes a paused sheet. - /// - void ResumeSheet(string sheetId); - - /// - /// Cancels a sheet job. - /// - void CancelSheet(string sheetId); - - /// - /// Cancels and removes a sheet job and its persisted state. - /// - void CancelAndRemoveSheet(string sheetId); - - /// - /// Requests pause for all running groups. - /// - void PauseAll(); - - /// - /// Resumes all paused groups. - /// - void ResumeAll(); - - /// - /// Cancels all active groups. - /// - void CancelAll(); - - /// - /// Gets running groups. - /// - IReadOnlyDictionary GetRunningGroups(); - - /// - /// Gets paused groups. - /// - IReadOnlyDictionary GetPausedGroups(); - - /// - /// Gets pending groups. - /// - IReadOnlyDictionary GetPendingGroups(); - - /// - /// Gets a group by output folder path, if present. - /// - IJobGroup? GetGroupByOutputPath(string outputFolderPath); -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Application/Features/Jobs/Contracts/Collections/ICompletedJobCollection.cs b/backend/src/SlideGenerator.Application/Features/Jobs/Contracts/Collections/ICompletedJobCollection.cs deleted file mode 100644 index 9bf80483..00000000 --- a/backend/src/SlideGenerator.Application/Features/Jobs/Contracts/Collections/ICompletedJobCollection.cs +++ /dev/null @@ -1,39 +0,0 @@ -using SlideGenerator.Domain.Features.Jobs.Interfaces; - -namespace SlideGenerator.Application.Features.Jobs.Contracts.Collections; - -/// -/// Manages completed jobs (finished/failed/cancelled). -/// -public interface ICompletedJobCollection : IJobCollection -{ - /// - /// Removes a completed group by id. - /// - bool RemoveGroup(string groupId); - - /// - /// Removes a completed sheet by id. - /// - bool RemoveSheet(string sheetId); - - /// - /// Clears all completed jobs. - /// - void ClearAll(); - - /// - /// Gets groups that completed successfully. - /// - IReadOnlyDictionary GetSuccessfulGroups(); - - /// - /// Gets groups that failed. - /// - IReadOnlyDictionary GetFailedGroups(); - - /// - /// Gets groups that were cancelled. - /// - IReadOnlyDictionary GetCancelledGroups(); -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Application/Features/Jobs/Contracts/Collections/IJobCollection.cs b/backend/src/SlideGenerator.Application/Features/Jobs/Contracts/Collections/IJobCollection.cs deleted file mode 100644 index f2fa56b7..00000000 --- a/backend/src/SlideGenerator.Application/Features/Jobs/Contracts/Collections/IJobCollection.cs +++ /dev/null @@ -1,64 +0,0 @@ -using SlideGenerator.Domain.Features.Jobs.Interfaces; - -namespace SlideGenerator.Application.Features.Jobs.Contracts.Collections; - -/// -/// Base job collection interface. -/// -public interface IJobCollection -{ - /// - /// Gets the count of groups. - /// - int GroupCount { get; } - - /// - /// Gets the count of sheets. - /// - int SheetCount { get; } - - /// - /// Gets a value indicating whether the collection is empty. - /// - bool IsEmpty { get; } - - /// - /// Gets a group by id. - /// - IJobGroup? GetGroup(string groupId); - - /// - /// Gets all groups in the collection. - /// - IReadOnlyDictionary GetAllGroups(); - - /// - /// Enumerates all groups in the collection. - /// - IEnumerable EnumerateGroups(); - - /// - /// Gets a sheet by id. - /// - IJobSheet? GetSheet(string sheetId); - - /// - /// Gets all sheets in the collection. - /// - IReadOnlyDictionary GetAllSheets(); - - /// - /// Enumerates all sheets in the collection. - /// - IEnumerable EnumerateSheets(); - - /// - /// Checks if the group id exists in the collection. - /// - bool ContainsGroup(string groupId); - - /// - /// Checks if the sheet id exists in the collection. - /// - bool ContainsSheet(string sheetId); -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Application/Features/Jobs/Contracts/IJobExecutor.cs b/backend/src/SlideGenerator.Application/Features/Jobs/Contracts/IJobExecutor.cs deleted file mode 100644 index b8ea0af1..00000000 --- a/backend/src/SlideGenerator.Application/Features/Jobs/Contracts/IJobExecutor.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace SlideGenerator.Application.Features.Jobs.Contracts; - -/// -/// Executes sheet jobs in the background worker. -/// -public interface IJobExecutor -{ - /// - /// Executes a sheet job by id in a background worker. - /// The job will be displayed in Hangfire dashboard as "WorkbookName/SheetName". - /// - Task ExecuteJobAsync(string sheetId, CancellationToken cancellationToken); -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Application/Features/Jobs/Contracts/IJobManager.cs b/backend/src/SlideGenerator.Application/Features/Jobs/Contracts/IJobManager.cs deleted file mode 100644 index 55be6c4a..00000000 --- a/backend/src/SlideGenerator.Application/Features/Jobs/Contracts/IJobManager.cs +++ /dev/null @@ -1,35 +0,0 @@ -using SlideGenerator.Application.Features.Jobs.Contracts.Collections; -using SlideGenerator.Domain.Features.Jobs.Interfaces; - -namespace SlideGenerator.Application.Features.Jobs.Contracts; - -/// -/// Provides access to active and completed job collections. -/// -public interface IJobManager -{ - /// - /// Active (pending/running/paused) job collection. - /// - IActiveJobCollection Active { get; } - - /// - /// Completed/failed/cancelled job collection. - /// - ICompletedJobCollection Completed { get; } - - /// - /// Gets a job group by id from either collection. - /// - IJobGroup? GetGroup(string groupId); - - /// - /// Gets a sheet job by id from either collection. - /// - IJobSheet? GetSheet(string sheetId); - - /// - /// Gets all job groups across active and completed collections. - /// - IReadOnlyDictionary GetAllGroups(); -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Application/Features/Jobs/Contracts/IJobNotifier.cs b/backend/src/SlideGenerator.Application/Features/Jobs/Contracts/IJobNotifier.cs deleted file mode 100644 index 3566d470..00000000 --- a/backend/src/SlideGenerator.Application/Features/Jobs/Contracts/IJobNotifier.cs +++ /dev/null @@ -1,40 +0,0 @@ -using SlideGenerator.Domain.Features.Jobs.Enums; -using SlideGenerator.Domain.Features.Jobs.Notifications; - -namespace SlideGenerator.Application.Features.Jobs.Contracts; - -/// -/// Sends realtime job notifications to subscribers. -/// -public interface IJobNotifier -{ - /// - /// Notifies subscribers of sheet progress updates. - /// - Task NotifyJobProgress(string jobId, int currentRow, int totalRows, float progress, int errorCount); - - /// - /// Notifies subscribers of sheet status changes. - /// - Task NotifyJobStatusChanged(string jobId, SheetJobStatus status, string? message = null); - - /// - /// Notifies subscribers of a sheet-level error. - /// - Task NotifyJobError(string jobId, string error); - - /// - /// Notifies subscribers of group progress updates. - /// - Task NotifyGroupProgress(string groupId, float progress, int errorCount); - - /// - /// Notifies subscribers of group status changes. - /// - Task NotifyGroupStatusChanged(string groupId, GroupStatus status, string? message = null); - - /// - /// Publishes a structured log event to subscribers. - /// - Task NotifyLog(JobEvent jobEvent); -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Application/Features/Jobs/DTOs/Requests/JobControl.cs b/backend/src/SlideGenerator.Application/Features/Jobs/DTOs/Requests/JobControl.cs deleted file mode 100644 index fab5678d..00000000 --- a/backend/src/SlideGenerator.Application/Features/Jobs/DTOs/Requests/JobControl.cs +++ /dev/null @@ -1,16 +0,0 @@ -using SlideGenerator.Application.Features.Slides.DTOs.Enums; -using SlideGenerator.Domain.Features.Jobs.Enums; - -namespace SlideGenerator.Application.Features.Jobs.DTOs.Requests; - -/// -/// Request to control a job. -/// -public sealed record JobControl -{ - public string JobId { get; init; } = string.Empty; - - public JobType? JobType { get; init; } - - public ControlAction Action { get; init; } = ControlAction.Pause; -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Application/Features/Jobs/DTOs/Requests/JobCreate.cs b/backend/src/SlideGenerator.Application/Features/Jobs/DTOs/Requests/JobCreate.cs deleted file mode 100644 index bf5fbb67..00000000 --- a/backend/src/SlideGenerator.Application/Features/Jobs/DTOs/Requests/JobCreate.cs +++ /dev/null @@ -1,31 +0,0 @@ -using SlideGenerator.Application.Features.Slides.DTOs.Components; -using SlideGenerator.Domain.Features.Jobs.Enums; - -namespace SlideGenerator.Application.Features.Jobs.DTOs.Requests; - -/// -/// Request to create a job (group or sheet). -/// -public sealed record JobCreate -{ - public JobType JobType { get; init; } = JobType.Group; - - public string TemplatePath { get; init; } = string.Empty; - - public string SpreadsheetPath { get; init; } = string.Empty; - - /// - /// For group jobs: output folder. For sheet jobs: output file or folder. - /// - public string OutputPath { get; init; } = string.Empty; - - public string[]? SheetNames { get; init; } - - public string? SheetName { get; init; } - - public SlideTextConfig[]? TextConfigs { get; init; } - - public SlideImageConfig[]? ImageConfigs { get; init; } - - public bool AutoStart { get; init; } = true; -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Application/Features/Jobs/DTOs/Requests/JobQuery.cs b/backend/src/SlideGenerator.Application/Features/Jobs/DTOs/Requests/JobQuery.cs deleted file mode 100644 index f6e7a858..00000000 --- a/backend/src/SlideGenerator.Application/Features/Jobs/DTOs/Requests/JobQuery.cs +++ /dev/null @@ -1,19 +0,0 @@ -using SlideGenerator.Domain.Features.Jobs.Enums; - -namespace SlideGenerator.Application.Features.Jobs.DTOs.Requests; - -/// -/// Request to query jobs. -/// -public sealed record JobQuery -{ - public string? JobId { get; init; } - - public JobType? JobType { get; init; } - - public JobQueryScope Scope { get; init; } = JobQueryScope.All; - - public bool IncludeSheets { get; init; } = true; - - public bool IncludePayload { get; init; } -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Application/Features/Jobs/DTOs/Requests/JobQueryScope.cs b/backend/src/SlideGenerator.Application/Features/Jobs/DTOs/Requests/JobQueryScope.cs deleted file mode 100644 index d35e733a..00000000 --- a/backend/src/SlideGenerator.Application/Features/Jobs/DTOs/Requests/JobQueryScope.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.Text.Json.Serialization; - -namespace SlideGenerator.Application.Features.Jobs.DTOs.Requests; - -/// -/// Defines job query scope. -/// -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum JobQueryScope -{ - Active, - Completed, - All -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Application/Features/Jobs/DTOs/Responses/Successes/JobControlSuccess.cs b/backend/src/SlideGenerator.Application/Features/Jobs/DTOs/Responses/Successes/JobControlSuccess.cs deleted file mode 100644 index daf96e88..00000000 --- a/backend/src/SlideGenerator.Application/Features/Jobs/DTOs/Responses/Successes/JobControlSuccess.cs +++ /dev/null @@ -1,14 +0,0 @@ -using SlideGenerator.Application.Common.Base.DTOs.Responses; -using SlideGenerator.Application.Features.Slides.DTOs.Enums; -using SlideGenerator.Domain.Features.Jobs.Enums; - -namespace SlideGenerator.Application.Features.Jobs.DTOs.Responses.Successes; - -/// -/// Response for job control. -/// -public sealed record JobControlSuccess( - string JobId, - JobType JobType, - ControlAction Action) - : Response("jobcontrol"); \ No newline at end of file diff --git a/backend/src/SlideGenerator.Application/Features/Jobs/DTOs/Responses/Successes/JobCreateSuccess.cs b/backend/src/SlideGenerator.Application/Features/Jobs/DTOs/Responses/Successes/JobCreateSuccess.cs deleted file mode 100644 index 5d032acd..00000000 --- a/backend/src/SlideGenerator.Application/Features/Jobs/DTOs/Responses/Successes/JobCreateSuccess.cs +++ /dev/null @@ -1,11 +0,0 @@ -using SlideGenerator.Application.Common.Base.DTOs.Responses; - -namespace SlideGenerator.Application.Features.Jobs.DTOs.Responses.Successes; - -/// -/// Response for job creation. -/// -public sealed record JobCreateSuccess( - JobSummary Job, - IReadOnlyDictionary? SheetJobIds) - : Response("jobcreate"); \ No newline at end of file diff --git a/backend/src/SlideGenerator.Application/Features/Jobs/DTOs/Responses/Successes/JobDetail.cs b/backend/src/SlideGenerator.Application/Features/Jobs/DTOs/Responses/Successes/JobDetail.cs deleted file mode 100644 index cb0a1ef2..00000000 --- a/backend/src/SlideGenerator.Application/Features/Jobs/DTOs/Responses/Successes/JobDetail.cs +++ /dev/null @@ -1,23 +0,0 @@ -using SlideGenerator.Domain.Features.Jobs.Enums; - -namespace SlideGenerator.Application.Features.Jobs.DTOs.Responses.Successes; - -/// -/// Detailed job information. -/// -public sealed record JobDetail( - string JobId, - JobType JobType, - JobState Status, - float Progress, - int ErrorCount, - string? ErrorMessage, - string? GroupId, - string? SheetName, - int? CurrentRow, - int? TotalRows, - string? OutputPath, - string? OutputFolder, - IReadOnlyDictionary? Sheets, - string? PayloadJson, - string? HangfireJobId); \ No newline at end of file diff --git a/backend/src/SlideGenerator.Application/Features/Jobs/DTOs/Responses/Successes/JobQuerySuccess.cs b/backend/src/SlideGenerator.Application/Features/Jobs/DTOs/Responses/Successes/JobQuerySuccess.cs deleted file mode 100644 index ac488fdb..00000000 --- a/backend/src/SlideGenerator.Application/Features/Jobs/DTOs/Responses/Successes/JobQuerySuccess.cs +++ /dev/null @@ -1,11 +0,0 @@ -using SlideGenerator.Application.Common.Base.DTOs.Responses; - -namespace SlideGenerator.Application.Features.Jobs.DTOs.Responses.Successes; - -/// -/// Response for job queries. -/// -public sealed record JobQuerySuccess( - JobDetail? Job, - IReadOnlyList? Jobs) - : Response("jobquery"); \ No newline at end of file diff --git a/backend/src/SlideGenerator.Application/Features/Jobs/DTOs/Responses/Successes/JobSummary.cs b/backend/src/SlideGenerator.Application/Features/Jobs/DTOs/Responses/Successes/JobSummary.cs deleted file mode 100644 index 6eb4033e..00000000 --- a/backend/src/SlideGenerator.Application/Features/Jobs/DTOs/Responses/Successes/JobSummary.cs +++ /dev/null @@ -1,17 +0,0 @@ -using SlideGenerator.Domain.Features.Jobs.Enums; - -namespace SlideGenerator.Application.Features.Jobs.DTOs.Responses.Successes; - -/// -/// Summary information for a job. -/// -public sealed record JobSummary( - string JobId, - JobType JobType, - JobState Status, - float Progress, - string? GroupId, - string? SheetName, - string? OutputPath, - int ErrorCount, - string? HangfireJobId); \ No newline at end of file diff --git a/backend/src/SlideGenerator.Application/Features/Jobs/JobSignalRGroups.cs b/backend/src/SlideGenerator.Application/Features/Jobs/JobSignalRGroups.cs deleted file mode 100644 index 824fe457..00000000 --- a/backend/src/SlideGenerator.Application/Features/Jobs/JobSignalRGroups.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace SlideGenerator.Application.Features.Jobs; - -/// -/// SignalR group naming helper for job subscriptions. -/// -public static class JobSignalRGroups -{ - /// - /// Gets the SignalR group name for a group job. - /// - public static string GroupGroup(string groupId) - { - return $"group:{groupId}"; - } - - /// - /// Gets the SignalR group name for a sheet job. - /// - public static string SheetGroup(string sheetId) - { - return $"sheet:{sheetId}"; - } -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Application/Features/Jobs/JobStateMapper.cs b/backend/src/SlideGenerator.Application/Features/Jobs/JobStateMapper.cs deleted file mode 100644 index 01a85a5a..00000000 --- a/backend/src/SlideGenerator.Application/Features/Jobs/JobStateMapper.cs +++ /dev/null @@ -1,37 +0,0 @@ -using SlideGenerator.Domain.Features.Jobs.Enums; - -namespace SlideGenerator.Application.Features.Jobs; - -/// -/// Maps job statuses to job states for API responses. -/// -public static class JobStateMapper -{ - public static JobState ToJobState(this GroupStatus status) - { - return status switch - { - GroupStatus.Pending => JobState.Pending, - GroupStatus.Running => JobState.Processing, - GroupStatus.Paused => JobState.Paused, - GroupStatus.Completed => JobState.Done, - GroupStatus.Cancelled => JobState.Cancelled, - GroupStatus.Failed => JobState.Error, - _ => JobState.Error - }; - } - - public static JobState ToJobState(this SheetJobStatus status) - { - return status switch - { - SheetJobStatus.Pending => JobState.Pending, - SheetJobStatus.Running => JobState.Processing, - SheetJobStatus.Paused => JobState.Paused, - SheetJobStatus.Completed => JobState.Done, - SheetJobStatus.Cancelled => JobState.Cancelled, - SheetJobStatus.Failed => JobState.Error, - _ => JobState.Error - }; - } -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Application/Features/Sheets/DTOs/Components/SheetWorksheetInfo.cs b/backend/src/SlideGenerator.Application/Features/Sheets/DTOs/Components/SheetWorksheetInfo.cs deleted file mode 100644 index 55a0a134..00000000 --- a/backend/src/SlideGenerator.Application/Features/Sheets/DTOs/Components/SheetWorksheetInfo.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace SlideGenerator.Application.Features.Sheets.DTOs.Components; - -/// -/// Worksheet info for workbook inspection. -/// -public sealed record SheetWorksheetInfo(string Name, IReadOnlyList Headers, int RowCount); \ No newline at end of file diff --git a/backend/src/SlideGenerator.Application/Features/Sheets/DTOs/Requests/Workbook/GetWorkbookInfoRequest.cs b/backend/src/SlideGenerator.Application/Features/Sheets/DTOs/Requests/Workbook/GetWorkbookInfoRequest.cs deleted file mode 100644 index cf2b03aa..00000000 --- a/backend/src/SlideGenerator.Application/Features/Sheets/DTOs/Requests/Workbook/GetWorkbookInfoRequest.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace SlideGenerator.Application.Features.Sheets.DTOs.Requests.Workbook; - -/// -/// Request to retrieve workbook info including headers. -/// -public sealed record GetWorkbookInfoRequest(string FilePath); \ No newline at end of file diff --git a/backend/src/SlideGenerator.Application/Features/Sheets/DTOs/Requests/Workbook/SheetWorkbookClose.cs b/backend/src/SlideGenerator.Application/Features/Sheets/DTOs/Requests/Workbook/SheetWorkbookClose.cs deleted file mode 100644 index 205d232a..00000000 --- a/backend/src/SlideGenerator.Application/Features/Sheets/DTOs/Requests/Workbook/SheetWorkbookClose.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace SlideGenerator.Application.Features.Sheets.DTOs.Requests.Workbook; - -/// -/// Request to close a workbook file. -/// -public sealed record SheetWorkbookClose(string FilePath); \ No newline at end of file diff --git a/backend/src/SlideGenerator.Application/Features/Sheets/DTOs/Requests/Workbook/SheetWorkbookGetSheetInfo.cs b/backend/src/SlideGenerator.Application/Features/Sheets/DTOs/Requests/Workbook/SheetWorkbookGetSheetInfo.cs deleted file mode 100644 index f20428cf..00000000 --- a/backend/src/SlideGenerator.Application/Features/Sheets/DTOs/Requests/Workbook/SheetWorkbookGetSheetInfo.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace SlideGenerator.Application.Features.Sheets.DTOs.Requests.Workbook; - -/// -/// Request to retrieve sheet information for a workbook. -/// -public sealed record SheetWorkbookGetSheetInfo(string FilePath); \ No newline at end of file diff --git a/backend/src/SlideGenerator.Application/Features/Sheets/DTOs/Requests/Workbook/SheetWorkbookOpen.cs b/backend/src/SlideGenerator.Application/Features/Sheets/DTOs/Requests/Workbook/SheetWorkbookOpen.cs deleted file mode 100644 index 5ee8e1aa..00000000 --- a/backend/src/SlideGenerator.Application/Features/Sheets/DTOs/Requests/Workbook/SheetWorkbookOpen.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace SlideGenerator.Application.Features.Sheets.DTOs.Requests.Workbook; - -/// -/// Request to open a workbook file. -/// -public sealed record SheetWorkbookOpen(string FilePath); \ No newline at end of file diff --git a/backend/src/SlideGenerator.Application/Features/Sheets/DTOs/Requests/Worksheet/SheetWorksheetGetHeaders.cs b/backend/src/SlideGenerator.Application/Features/Sheets/DTOs/Requests/Worksheet/SheetWorksheetGetHeaders.cs deleted file mode 100644 index eb4533d4..00000000 --- a/backend/src/SlideGenerator.Application/Features/Sheets/DTOs/Requests/Worksheet/SheetWorksheetGetHeaders.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace SlideGenerator.Application.Features.Sheets.DTOs.Requests.Worksheet; - -/// -/// Request to retrieve headers for a worksheet. -/// -public sealed record SheetWorksheetGetHeaders(string FilePath, string SheetName); \ No newline at end of file diff --git a/backend/src/SlideGenerator.Application/Features/Sheets/DTOs/Requests/Worksheet/SheetWorksheetGetRow.cs b/backend/src/SlideGenerator.Application/Features/Sheets/DTOs/Requests/Worksheet/SheetWorksheetGetRow.cs deleted file mode 100644 index fdcf9654..00000000 --- a/backend/src/SlideGenerator.Application/Features/Sheets/DTOs/Requests/Worksheet/SheetWorksheetGetRow.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace SlideGenerator.Application.Features.Sheets.DTOs.Requests.Worksheet; - -/// -/// Request to retrieve a row from a worksheet. -/// -public sealed record SheetWorksheetGetRow(string FilePath, string TableName, int RowNumber); \ No newline at end of file diff --git a/backend/src/SlideGenerator.Application/Features/Sheets/DTOs/Responses/Errors/SheetError.cs b/backend/src/SlideGenerator.Application/Features/Sheets/DTOs/Responses/Errors/SheetError.cs deleted file mode 100644 index 8387ace7..00000000 --- a/backend/src/SlideGenerator.Application/Features/Sheets/DTOs/Responses/Errors/SheetError.cs +++ /dev/null @@ -1,15 +0,0 @@ -using SlideGenerator.Application.Common.Base.DTOs.Responses; - -namespace SlideGenerator.Application.Features.Sheets.DTOs.Responses.Errors; - -/// -/// Error response for worksheet operations. -/// -public sealed record SheetError(string FilePath, string Kind, string Message) - : Response("error") -{ - public SheetError(string filePath, Exception exception) - : this(filePath, exception.GetType().Name, exception.Message) - { - } -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Application/Features/Sheets/DTOs/Responses/Successes/Workbook/OpenBookSheetSuccess.cs b/backend/src/SlideGenerator.Application/Features/Sheets/DTOs/Responses/Successes/Workbook/OpenBookSheetSuccess.cs deleted file mode 100644 index e5ffed15..00000000 --- a/backend/src/SlideGenerator.Application/Features/Sheets/DTOs/Responses/Successes/Workbook/OpenBookSheetSuccess.cs +++ /dev/null @@ -1,8 +0,0 @@ -using SlideGenerator.Application.Common.Base.DTOs.Responses; - -namespace SlideGenerator.Application.Features.Sheets.DTOs.Responses.Successes.Workbook; - -/// -/// Response indicating a workbook has been opened. -/// -public sealed record OpenBookSheetSuccess(string FilePath) : Response("openfile"); \ No newline at end of file diff --git a/backend/src/SlideGenerator.Application/Features/Sheets/DTOs/Responses/Successes/Workbook/SheetWorkbookCloseSuccess.cs b/backend/src/SlideGenerator.Application/Features/Sheets/DTOs/Responses/Successes/Workbook/SheetWorkbookCloseSuccess.cs deleted file mode 100644 index 7f0e34bc..00000000 --- a/backend/src/SlideGenerator.Application/Features/Sheets/DTOs/Responses/Successes/Workbook/SheetWorkbookCloseSuccess.cs +++ /dev/null @@ -1,8 +0,0 @@ -using SlideGenerator.Application.Common.Base.DTOs.Responses; - -namespace SlideGenerator.Application.Features.Sheets.DTOs.Responses.Successes.Workbook; - -/// -/// Response indicating a workbook has been closed. -/// -public sealed record SheetWorkbookCloseSuccess(string FilePath) : Response("closefile"); \ No newline at end of file diff --git a/backend/src/SlideGenerator.Application/Features/Sheets/DTOs/Responses/Successes/Workbook/SheetWorkbookGetInfoSuccess.cs b/backend/src/SlideGenerator.Application/Features/Sheets/DTOs/Responses/Successes/Workbook/SheetWorkbookGetInfoSuccess.cs deleted file mode 100644 index a335fb61..00000000 --- a/backend/src/SlideGenerator.Application/Features/Sheets/DTOs/Responses/Successes/Workbook/SheetWorkbookGetInfoSuccess.cs +++ /dev/null @@ -1,13 +0,0 @@ -using SlideGenerator.Application.Common.Base.DTOs.Responses; -using SlideGenerator.Application.Features.Sheets.DTOs.Components; - -namespace SlideGenerator.Application.Features.Sheets.DTOs.Responses.Successes.Workbook; - -/// -/// Response containing workbook inspection details. -/// -public sealed record SheetWorkbookGetInfoSuccess( - string FilePath, - string? WorkbookName, - IReadOnlyList Sheets) - : Response("getworkbookinfo"); \ No newline at end of file diff --git a/backend/src/SlideGenerator.Application/Features/Sheets/DTOs/Responses/Successes/Workbook/SheetWorkbookGetSheetInfoSuccess.cs b/backend/src/SlideGenerator.Application/Features/Sheets/DTOs/Responses/Successes/Workbook/SheetWorkbookGetSheetInfoSuccess.cs deleted file mode 100644 index b2030127..00000000 --- a/backend/src/SlideGenerator.Application/Features/Sheets/DTOs/Responses/Successes/Workbook/SheetWorkbookGetSheetInfoSuccess.cs +++ /dev/null @@ -1,11 +0,0 @@ -using SlideGenerator.Application.Common.Base.DTOs.Responses; - -namespace SlideGenerator.Application.Features.Sheets.DTOs.Responses.Successes.Workbook; - -/// -/// Response containing worksheet counts. -/// -public sealed record SheetWorkbookGetSheetInfoSuccess( - string FilePath, - IReadOnlyDictionary Sheets) - : Response("gettables"); \ No newline at end of file diff --git a/backend/src/SlideGenerator.Application/Features/Sheets/DTOs/Responses/Successes/Worksheet/SheetWorksheetGetHeadersSuccess.cs b/backend/src/SlideGenerator.Application/Features/Sheets/DTOs/Responses/Successes/Worksheet/SheetWorksheetGetHeadersSuccess.cs deleted file mode 100644 index 26897dbb..00000000 --- a/backend/src/SlideGenerator.Application/Features/Sheets/DTOs/Responses/Successes/Worksheet/SheetWorksheetGetHeadersSuccess.cs +++ /dev/null @@ -1,12 +0,0 @@ -using SlideGenerator.Application.Common.Base.DTOs.Responses; - -namespace SlideGenerator.Application.Features.Sheets.DTOs.Responses.Successes.Worksheet; - -/// -/// Response containing worksheet headers. -/// -public sealed record SheetWorksheetGetHeadersSuccess( - string FilePath, - string SheetName, - IReadOnlyList Headers) - : Response("getheaders"); \ No newline at end of file diff --git a/backend/src/SlideGenerator.Application/Features/Sheets/DTOs/Responses/Successes/Worksheet/SheetWorksheetGetRowSuccess.cs b/backend/src/SlideGenerator.Application/Features/Sheets/DTOs/Responses/Successes/Worksheet/SheetWorksheetGetRowSuccess.cs deleted file mode 100644 index 57736b9b..00000000 --- a/backend/src/SlideGenerator.Application/Features/Sheets/DTOs/Responses/Successes/Worksheet/SheetWorksheetGetRowSuccess.cs +++ /dev/null @@ -1,13 +0,0 @@ -using SlideGenerator.Application.Common.Base.DTOs.Responses; - -namespace SlideGenerator.Application.Features.Sheets.DTOs.Responses.Successes.Worksheet; - -/// -/// Response containing worksheet row data. -/// -public sealed record SheetWorksheetGetRowSuccess( - string FilePath, - string TableName, - int RowNumber, - Dictionary Row) - : Response("getrow"); \ No newline at end of file diff --git a/backend/src/SlideGenerator.Application/Features/Sheets/ISheetService.cs b/backend/src/SlideGenerator.Application/Features/Sheets/ISheetService.cs deleted file mode 100644 index 39dad327..00000000 --- a/backend/src/SlideGenerator.Application/Features/Sheets/ISheetService.cs +++ /dev/null @@ -1,16 +0,0 @@ -using SlideGenerator.Domain.Features.Sheets.Interfaces; - -namespace SlideGenerator.Application.Features.Sheets; - -using RowContent = Dictionary; - -/// -/// Interface for sheet processing service. -/// -public interface ISheetService -{ - ISheetBook OpenFile(string filePath); - IReadOnlyDictionary GetSheetsInfo(ISheetBook group); - IReadOnlyList GetHeaders(ISheetBook group, string tableName); - RowContent GetRow(ISheetBook group, string tableName, int rowNumber); -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Application/Features/Slides/DTOs/Components/ShapeDto.cs b/backend/src/SlideGenerator.Application/Features/Slides/DTOs/Components/ShapeDto.cs deleted file mode 100644 index 138566b7..00000000 --- a/backend/src/SlideGenerator.Application/Features/Slides/DTOs/Components/ShapeDto.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace SlideGenerator.Application.Features.Slides.DTOs.Components; - -/// -/// Shape information used for placeholder mapping. -/// -public sealed record ShapeDto(uint Id, string Name, string Data, string Kind = "Image", bool IsImage = true); \ No newline at end of file diff --git a/backend/src/SlideGenerator.Application/Features/Slides/DTOs/Components/SlideImageConfig.cs b/backend/src/SlideGenerator.Application/Features/Slides/DTOs/Components/SlideImageConfig.cs deleted file mode 100644 index 0aa6f11c..00000000 --- a/backend/src/SlideGenerator.Application/Features/Slides/DTOs/Components/SlideImageConfig.cs +++ /dev/null @@ -1,8 +0,0 @@ -using SlideGenerator.Domain.Features.Images.Enums; - -namespace SlideGenerator.Application.Features.Slides.DTOs.Components; - -/// -/// Image replacement configuration provided by the client. -/// -public sealed record SlideImageConfig(uint ShapeId, string[] Columns, ImageRoiType? RoiType, ImageCropType? CropType); \ No newline at end of file diff --git a/backend/src/SlideGenerator.Application/Features/Slides/DTOs/Components/SlideTextConfig.cs b/backend/src/SlideGenerator.Application/Features/Slides/DTOs/Components/SlideTextConfig.cs deleted file mode 100644 index de066aa7..00000000 --- a/backend/src/SlideGenerator.Application/Features/Slides/DTOs/Components/SlideTextConfig.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace SlideGenerator.Application.Features.Slides.DTOs.Components; - -/// -/// Text replacement configuration provided by the client. -/// -public sealed record SlideTextConfig(string Pattern, string[] Columns); \ No newline at end of file diff --git a/backend/src/SlideGenerator.Application/Features/Slides/DTOs/Enums/ControlAction.cs b/backend/src/SlideGenerator.Application/Features/Slides/DTOs/Enums/ControlAction.cs deleted file mode 100644 index 2017b858..00000000 --- a/backend/src/SlideGenerator.Application/Features/Slides/DTOs/Enums/ControlAction.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.Text.Json.Serialization; - -namespace SlideGenerator.Application.Features.Slides.DTOs.Enums; - -/// -/// Control actions for job execution. -/// -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum ControlAction -{ - Pause, - Resume, - Cancel, - Stop, - Remove -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Application/Features/Slides/DTOs/Notifications/GroupProgressNotification.cs b/backend/src/SlideGenerator.Application/Features/Slides/DTOs/Notifications/GroupProgressNotification.cs deleted file mode 100644 index eafe2489..00000000 --- a/backend/src/SlideGenerator.Application/Features/Slides/DTOs/Notifications/GroupProgressNotification.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace SlideGenerator.Application.Features.Slides.DTOs.Notifications; - -/// -/// Notification for group progress updates. -/// -public sealed record GroupProgressNotification( - string GroupId, - float Progress, - int ErrorCount, - DateTimeOffset Timestamp); \ No newline at end of file diff --git a/backend/src/SlideGenerator.Application/Features/Slides/DTOs/Notifications/GroupStatusNotification.cs b/backend/src/SlideGenerator.Application/Features/Slides/DTOs/Notifications/GroupStatusNotification.cs deleted file mode 100644 index eb86cb8b..00000000 --- a/backend/src/SlideGenerator.Application/Features/Slides/DTOs/Notifications/GroupStatusNotification.cs +++ /dev/null @@ -1,12 +0,0 @@ -using SlideGenerator.Domain.Features.Jobs.Enums; - -namespace SlideGenerator.Application.Features.Slides.DTOs.Notifications; - -/// -/// Notification for group status changes. -/// -public sealed record GroupStatusNotification( - string GroupId, - GroupStatus Status, - string? Message, - DateTimeOffset Timestamp); \ No newline at end of file diff --git a/backend/src/SlideGenerator.Application/Features/Slides/DTOs/Notifications/JobErrorNotification.cs b/backend/src/SlideGenerator.Application/Features/Slides/DTOs/Notifications/JobErrorNotification.cs deleted file mode 100644 index 16744f50..00000000 --- a/backend/src/SlideGenerator.Application/Features/Slides/DTOs/Notifications/JobErrorNotification.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace SlideGenerator.Application.Features.Slides.DTOs.Notifications; - -/// -/// Notification for sheet job errors. -/// -public sealed record JobErrorNotification( - string JobId, - string Error, - DateTimeOffset Timestamp); \ No newline at end of file diff --git a/backend/src/SlideGenerator.Application/Features/Slides/DTOs/Notifications/JobLogNotification.cs b/backend/src/SlideGenerator.Application/Features/Slides/DTOs/Notifications/JobLogNotification.cs deleted file mode 100644 index aed2a343..00000000 --- a/backend/src/SlideGenerator.Application/Features/Slides/DTOs/Notifications/JobLogNotification.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace SlideGenerator.Application.Features.Slides.DTOs.Notifications; - -/// -/// Notification for realtime log messages. -/// -public sealed record JobLogNotification( - string JobId, - string Level, - string Message, - DateTimeOffset Timestamp, - IReadOnlyDictionary? Data = null); \ No newline at end of file diff --git a/backend/src/SlideGenerator.Application/Features/Slides/DTOs/Notifications/JobProgressNotification.cs b/backend/src/SlideGenerator.Application/Features/Slides/DTOs/Notifications/JobProgressNotification.cs deleted file mode 100644 index 4c6032ba..00000000 --- a/backend/src/SlideGenerator.Application/Features/Slides/DTOs/Notifications/JobProgressNotification.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace SlideGenerator.Application.Features.Slides.DTOs.Notifications; - -/// -/// Notification for sheet job progress updates. -/// -public sealed record JobProgressNotification( - string JobId, - int CurrentRow, - int TotalRows, - float Progress, - int ErrorCount, - DateTimeOffset Timestamp); \ No newline at end of file diff --git a/backend/src/SlideGenerator.Application/Features/Slides/DTOs/Notifications/JobStatusNotification.cs b/backend/src/SlideGenerator.Application/Features/Slides/DTOs/Notifications/JobStatusNotification.cs deleted file mode 100644 index 0d796f73..00000000 --- a/backend/src/SlideGenerator.Application/Features/Slides/DTOs/Notifications/JobStatusNotification.cs +++ /dev/null @@ -1,12 +0,0 @@ -using SlideGenerator.Domain.Features.Jobs.Enums; - -namespace SlideGenerator.Application.Features.Slides.DTOs.Notifications; - -/// -/// Notification for sheet job status changes. -/// -public sealed record JobStatusNotification( - string JobId, - SheetJobStatus Status, - string? Message, - DateTimeOffset Timestamp); \ No newline at end of file diff --git a/backend/src/SlideGenerator.Application/Features/Slides/DTOs/Requests/SlideScanPlaceholders.cs b/backend/src/SlideGenerator.Application/Features/Slides/DTOs/Requests/SlideScanPlaceholders.cs deleted file mode 100644 index e541ab49..00000000 --- a/backend/src/SlideGenerator.Application/Features/Slides/DTOs/Requests/SlideScanPlaceholders.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace SlideGenerator.Application.Features.Slides.DTOs.Requests; - -/// -/// Request to scan text placeholders from a template presentation. -/// -public sealed record SlideScanPlaceholders(string FilePath); \ No newline at end of file diff --git a/backend/src/SlideGenerator.Application/Features/Slides/DTOs/Requests/SlideScanShapes.cs b/backend/src/SlideGenerator.Application/Features/Slides/DTOs/Requests/SlideScanShapes.cs deleted file mode 100644 index 004b9396..00000000 --- a/backend/src/SlideGenerator.Application/Features/Slides/DTOs/Requests/SlideScanShapes.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace SlideGenerator.Application.Features.Slides.DTOs.Requests; - -/// -/// Request to scan shapes from a template presentation. -/// -public sealed record SlideScanShapes(string FilePath); \ No newline at end of file diff --git a/backend/src/SlideGenerator.Application/Features/Slides/DTOs/Requests/SlideScanTemplate.cs b/backend/src/SlideGenerator.Application/Features/Slides/DTOs/Requests/SlideScanTemplate.cs deleted file mode 100644 index d56086ab..00000000 --- a/backend/src/SlideGenerator.Application/Features/Slides/DTOs/Requests/SlideScanTemplate.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace SlideGenerator.Application.Features.Slides.DTOs.Requests; - -/// -/// Request to scan shapes and placeholders from a template presentation. -/// -public sealed record SlideScanTemplate(string FilePath); \ No newline at end of file diff --git a/backend/src/SlideGenerator.Application/Features/Slides/DTOs/Responses/Errors/Error.cs b/backend/src/SlideGenerator.Application/Features/Slides/DTOs/Responses/Errors/Error.cs deleted file mode 100644 index 29de25b7..00000000 --- a/backend/src/SlideGenerator.Application/Features/Slides/DTOs/Responses/Errors/Error.cs +++ /dev/null @@ -1,14 +0,0 @@ -using SlideGenerator.Application.Common.Base.DTOs.Responses; - -namespace SlideGenerator.Application.Features.Slides.DTOs.Responses.Errors; - -/// -/// Error response for slide requests. -/// -public sealed record Error(string Kind, string Message) : Response("error") -{ - public Error(Exception exception) - : this(exception.GetType().Name, exception.Message) - { - } -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Application/Features/Slides/DTOs/Responses/Successes/SlideScanPlaceholdersSuccess.cs b/backend/src/SlideGenerator.Application/Features/Slides/DTOs/Responses/Successes/SlideScanPlaceholdersSuccess.cs deleted file mode 100644 index e33e11a4..00000000 --- a/backend/src/SlideGenerator.Application/Features/Slides/DTOs/Responses/Successes/SlideScanPlaceholdersSuccess.cs +++ /dev/null @@ -1,9 +0,0 @@ -using SlideGenerator.Application.Common.Base.DTOs.Responses; - -namespace SlideGenerator.Application.Features.Slides.DTOs.Responses.Successes; - -/// -/// Response containing text placeholders. -/// -public sealed record SlideScanPlaceholdersSuccess(string FilePath, string[] Placeholders) - : Response("scanplaceholders"); \ No newline at end of file diff --git a/backend/src/SlideGenerator.Application/Features/Slides/DTOs/Responses/Successes/SlideScanShapesSuccess.cs b/backend/src/SlideGenerator.Application/Features/Slides/DTOs/Responses/Successes/SlideScanShapesSuccess.cs deleted file mode 100644 index c0346f49..00000000 --- a/backend/src/SlideGenerator.Application/Features/Slides/DTOs/Responses/Successes/SlideScanShapesSuccess.cs +++ /dev/null @@ -1,10 +0,0 @@ -using SlideGenerator.Application.Common.Base.DTOs.Responses; -using SlideGenerator.Application.Features.Slides.DTOs.Components; - -namespace SlideGenerator.Application.Features.Slides.DTOs.Responses.Successes; - -/// -/// Response containing template shapes. -/// -public sealed record SlideScanShapesSuccess(string FilePath, ShapeDto[] Shapes) - : Response("scanshapes"); \ No newline at end of file diff --git a/backend/src/SlideGenerator.Application/Features/Slides/DTOs/Responses/Successes/SlideScanTemplateSuccess.cs b/backend/src/SlideGenerator.Application/Features/Slides/DTOs/Responses/Successes/SlideScanTemplateSuccess.cs deleted file mode 100644 index 96c62623..00000000 --- a/backend/src/SlideGenerator.Application/Features/Slides/DTOs/Responses/Successes/SlideScanTemplateSuccess.cs +++ /dev/null @@ -1,10 +0,0 @@ -using SlideGenerator.Application.Common.Base.DTOs.Responses; -using SlideGenerator.Application.Features.Slides.DTOs.Components; - -namespace SlideGenerator.Application.Features.Slides.DTOs.Responses.Successes; - -/// -/// Response containing shapes and text placeholders. -/// -public sealed record SlideScanTemplateSuccess(string FilePath, ShapeDto[] Shapes, string[] Placeholders) - : Response("scantemplate"); \ No newline at end of file diff --git a/backend/src/SlideGenerator.Application/Features/Slides/ISlideServices.cs b/backend/src/SlideGenerator.Application/Features/Slides/ISlideServices.cs deleted file mode 100644 index 76482de1..00000000 --- a/backend/src/SlideGenerator.Application/Features/Slides/ISlideServices.cs +++ /dev/null @@ -1,67 +0,0 @@ -using SlideGenerator.Domain.Features.Jobs.Components; - -namespace SlideGenerator.Application.Features.Slides; - -/// -/// Defines slide processing operations for a single row. -/// -public interface ISlideServices -{ - Task ProcessRowAsync( - string presentationPath, - JobTextConfig[] textConfigs, - JobImageConfig[] imageConfigs, - Dictionary rowData, - JobCheckpoint checkpoint, - CancellationToken cancellationToken); - - void RemoveFirstSlide(string presentationPath); -} - -/// -/// Result information for row processing. -/// -public sealed record RowProcessResult( - int TextReplacementCount, - int ImageReplacementCount, - int ImageErrorCount, - IReadOnlyList Errors, - IReadOnlyList TextReplacements, - IReadOnlyList ImageReplacements); - -/// -/// Details for a text replacement applied to a shape. -/// -public sealed record TextReplacementDetail( - uint ShapeId, - string Placeholder, - string Value); - -/// -/// Details for an image replacement applied to a shape. -/// -public sealed record ImageReplacementDetail( - uint ShapeId, - string Source); - -/// -/// Provides cooperative pause checkpoints during processing. -/// -public delegate Task JobCheckpoint(JobCheckpointStage stage, CancellationToken cancellationToken); - -/// -/// Represents checkpoints within a row execution. -/// -public enum JobCheckpointStage -{ - BeforeRow, - BeforeCloudResolve, - AfterCloudResolve, - BeforeDownload, - AfterDownload, - BeforeImageProcess, - AfterImageProcess, - BeforeSlideUpdate, - AfterSlideUpdate, - BeforePersistState -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Application/Features/Slides/ISlideTemplateManager.cs b/backend/src/SlideGenerator.Application/Features/Slides/ISlideTemplateManager.cs deleted file mode 100644 index 5dcb51bf..00000000 --- a/backend/src/SlideGenerator.Application/Features/Slides/ISlideTemplateManager.cs +++ /dev/null @@ -1,30 +0,0 @@ -using SlideGenerator.Domain.Features.Slides; - -namespace SlideGenerator.Application.Features.Slides; - -/// -/// Interface for template presentation service. -/// -public interface ISlideTemplateManager -{ - /// - /// Adds a template from the specified file path. - /// - /// The path to the template file to add. Cannot be null or empty. - /// if the template was added successfully; otherwise, . - bool AddTemplate(string filepath); - - /// - /// Removes the template file at the specified path. - /// - /// The full path to the template file to remove. Cannot be null or empty. - /// if the template was removed successfully; otherwise, . - bool RemoveTemplate(string filepath); - - /// - /// Retrieves a template presentation from the specified file path. - /// - /// The path to the template file to load. Cannot be null or empty. - /// An object representing the template presentation loaded from the specified file. - ITemplatePresentation GetTemplate(string filepath); -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Application/Features/Slides/ISlideWorkingManager.cs b/backend/src/SlideGenerator.Application/Features/Slides/ISlideWorkingManager.cs deleted file mode 100644 index 81495c5f..00000000 --- a/backend/src/SlideGenerator.Application/Features/Slides/ISlideWorkingManager.cs +++ /dev/null @@ -1,33 +0,0 @@ -using SlideGenerator.Domain.Features.Slides; - -namespace SlideGenerator.Application.Features.Slides; - -/// -/// Interface for generating presentation service. -/// -public interface ISlideWorkingManager -{ - /// - /// Adds a working presentation by copying content from the specified source path to the given file path. - /// - /// The file path where the working presentation will be created. Cannot be null or empty. - /// - /// if the working presentation was added successfully; otherwise, - /// . - /// - bool GetOrAddWorkingPresentation(string filepath); - - /// - /// Removes the working presentation file at the specified path. - /// - /// The full path to the working presentation file to remove. Cannot be null or empty. - /// if the file was successfully removed; otherwise, . - bool RemoveWorkingPresentation(string filepath); - - /// - /// Retrieves a working presentation from the specified file path. - /// - /// The path to the file containing the presentation to load. Cannot be null or empty. - /// An object representing the working presentation loaded from the specified file. - IWorkingPresentation GetWorkingPresentation(string filepath); -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Application/Properties/launchSettings.json b/backend/src/SlideGenerator.Application/Properties/launchSettings.json deleted file mode 100644 index 9e26dfee..00000000 --- a/backend/src/SlideGenerator.Application/Properties/launchSettings.json +++ /dev/null @@ -1 +0,0 @@ -{} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Application/SlideGenerator.Application.csproj b/backend/src/SlideGenerator.Application/SlideGenerator.Application.csproj deleted file mode 100644 index 4babb2b2..00000000 --- a/backend/src/SlideGenerator.Application/SlideGenerator.Application.csproj +++ /dev/null @@ -1,15 +0,0 @@ - - - - net10.0 - enable - enable - true - GPL-3.0-only - $(NoWarn);1591 - - - - - - diff --git a/backend/src/SlideGenerator.Configs/Contracts/IConfigProvider.cs b/backend/src/SlideGenerator.Configs/Contracts/IConfigProvider.cs new file mode 100644 index 00000000..b1840823 --- /dev/null +++ b/backend/src/SlideGenerator.Configs/Contracts/IConfigProvider.cs @@ -0,0 +1,15 @@ +using SlideGenerator.Configs.Entities; + +namespace SlideGenerator.Configs.Contracts; + +/// +/// Provides read-only access to the current configuration. +/// +/// This interface is intended for components that only need to read configuration values without modifying them. +public interface IConfigProvider +{ + /// + /// Gets current configuration. + /// + public Config Current { get; } +} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Configs/Entities/Config.DownloadConfig.cs b/backend/src/SlideGenerator.Configs/Entities/Config.DownloadConfig.cs new file mode 100644 index 00000000..77ffa324 --- /dev/null +++ b/backend/src/SlideGenerator.Configs/Entities/Config.DownloadConfig.cs @@ -0,0 +1,52 @@ +using System.Net; + +namespace SlideGenerator.Configs.Entities; + +public sealed partial class Config +{ + private static readonly string DefaultDownloadPath = Path.Combine(Path.GetTempPath(), AppName); + + public sealed class DownloadConfig + { + public bool DeleteAfterDownload = true; + public int LimitBytesPerSecond = 0; + public int MaxChunks = 5; + + public ProxyConfig Proxy = new(); + + public RetryConfig Retry = new(); + + public string SaveFolder + { + get => string.IsNullOrEmpty(field) ? DefaultDownloadPath : field; + set; + } = string.Empty; + + public sealed class RetryConfig + { + public int MaxRetries = 3; + public int Timeout = 30; + } + + public sealed class ProxyConfig + { + public string Domain = string.Empty; + public string Password = string.Empty; + public string ProxyAddress = string.Empty; + public bool UseProxy = false; + public string Username = string.Empty; + + public IWebProxy? GetWebProxy() + { + if (!UseProxy || string.IsNullOrEmpty(ProxyAddress)) + return null; + + var proxy = new WebProxy(ProxyAddress) + { + Credentials = new NetworkCredential(Username, Password, Domain) + }; + return proxy; + } + } + } +} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Configs/Entities/Config.ImageConfig.cs b/backend/src/SlideGenerator.Configs/Entities/Config.ImageConfig.cs new file mode 100644 index 00000000..86f2de55 --- /dev/null +++ b/backend/src/SlideGenerator.Configs/Entities/Config.ImageConfig.cs @@ -0,0 +1,25 @@ +namespace SlideGenerator.Configs.Entities; + +public partial class Config +{ + public sealed class ImageConfig + { + public FaceConfig Face = new(); + public SaliencyConfig Saliency = new(); + + public sealed class FaceConfig + { + public float Confidence = 0.7f; + public int MaxDimension = 1280; + public bool UnionAll = false; + } + + public sealed class SaliencyConfig + { + public float PaddingBottom = 0.0f; + public float PaddingLeft = 0.0f; + public float PaddingRight = 0.0f; + public float PaddingTop = 0.0f; + } + } +} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Configs/Entities/Config.JobConfig.cs b/backend/src/SlideGenerator.Configs/Entities/Config.JobConfig.cs new file mode 100644 index 00000000..2daff095 --- /dev/null +++ b/backend/src/SlideGenerator.Configs/Entities/Config.JobConfig.cs @@ -0,0 +1,12 @@ +namespace SlideGenerator.Configs.Entities; + +public sealed partial class Config +{ + public static readonly string DatabasePath = Path.Combine(AppContext.BaseDirectory, "Jobs.db"); + + public sealed class JobConfig + { + public int MaxConcurrentJobs = 5; + public int MaxRetries = 3; + } +} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Configs/Entities/Config.cs b/backend/src/SlideGenerator.Configs/Entities/Config.cs new file mode 100644 index 00000000..1b07e834 --- /dev/null +++ b/backend/src/SlideGenerator.Configs/Entities/Config.cs @@ -0,0 +1,10 @@ +namespace SlideGenerator.Configs.Entities; + +public sealed partial class Config +{ + private const string AppName = "SlideGenerator"; + + public DownloadConfig Download = new(); + public ImageConfig Image = new(); + public JobConfig Job = new(); +} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Configs/Services/ConfigManager.cs b/backend/src/SlideGenerator.Configs/Services/ConfigManager.cs new file mode 100644 index 00000000..cef90ef3 --- /dev/null +++ b/backend/src/SlideGenerator.Configs/Services/ConfigManager.cs @@ -0,0 +1,78 @@ +using SlideGenerator.Configs.Contracts; +using SlideGenerator.Configs.Entities; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace SlideGenerator.Configs.Services; + +public sealed class ConfigManager : IConfigProvider +{ + private static readonly string ConfigFilePath = Path.Combine(AppContext.BaseDirectory, "Configs.yaml"); + + private readonly IDeserializer _deserializer = new DeserializerBuilder() + .WithNamingConvention(UnderscoredNamingConvention.Instance) + .IgnoreUnmatchedProperties() + .Build(); + + private readonly ISerializer _serializer = new SerializerBuilder() + .WithNamingConvention(UnderscoredNamingConvention.Instance) + .Build(); + + public Config Current { get; private set; } = new(); + + public bool Load() + { + foreach (var candidate in EnumerateCandidates(ConfigFilePath)) + try + { + if (!File.Exists(candidate)) continue; + var yaml = File.ReadAllText(candidate); + var loaded = _deserializer.Deserialize(yaml); + + Current = loaded; + return true; + } + catch + { + // TODO: log the error + } + + return false; + } + + public bool Save() + { + var target = ResolveSavePath(ConfigFilePath); + try + { + Directory.CreateDirectory(Path.GetDirectoryName(target) ?? AppContext.BaseDirectory); + File.WriteAllText(target, _serializer.Serialize(Current)); + return true; + } + catch + { + return false; + } + } + + public bool ResetToDefaults() + { + Current = new Config(); + return Save(); + } + + private static IEnumerable EnumerateCandidates(string? explicitPath) + { + if (!string.IsNullOrWhiteSpace(explicitPath)) + yield return explicitPath; + + yield return ConfigFilePath; + } + + private static string ResolveSavePath(string? explicitPath) + { + return !string.IsNullOrWhiteSpace(explicitPath) + ? explicitPath + : ConfigFilePath; + } +} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Configs/SlideGenerator.Configs.csproj b/backend/src/SlideGenerator.Configs/SlideGenerator.Configs.csproj new file mode 100644 index 00000000..b550ff76 --- /dev/null +++ b/backend/src/SlideGenerator.Configs/SlideGenerator.Configs.csproj @@ -0,0 +1,23 @@ + + + + net10.0 + enable + enable + true + $(NoWarn);1591 + GPL-3.0-only + false + + + + + + + + + + + + + diff --git a/backend/src/SlideGenerator.Domain/Features/Configs/Config.DownloadConfig.cs b/backend/src/SlideGenerator.Domain/Features/Configs/Config.DownloadConfig.cs deleted file mode 100644 index a737c3d3..00000000 --- a/backend/src/SlideGenerator.Domain/Features/Configs/Config.DownloadConfig.cs +++ /dev/null @@ -1,49 +0,0 @@ -using System.Net; - -namespace SlideGenerator.Domain.Configs; - -public sealed partial class Config -{ - public sealed class DownloadConfig - { - public int MaxChunks { get; init; } = 5; - public int LimitBytesPerSecond { get; init; } = 0; - - public string SaveFolder - { - get => string.IsNullOrEmpty(field) ? DownloadTempPath : field; - init; - } = string.Empty; - - public RetryConfig Retry { get; init; } = new(); - - public ProxyConfig Proxy { get; init; } = new(); - - public class RetryConfig - { - public int Timeout { get; init; } = 30; - public int MaxRetries { get; init; } = 3; - } - - public class ProxyConfig - { - public bool UseProxy { get; init; } = false; - public string ProxyAddress { get; init; } = string.Empty; - public string Username { get; init; } = string.Empty; - public string Password { get; init; } = string.Empty; - public string Domain { get; init; } = string.Empty; - - public IWebProxy? GetWebProxy() - { - if (!UseProxy || string.IsNullOrEmpty(ProxyAddress)) - return null; - - var proxy = new WebProxy(ProxyAddress) - { - Credentials = new NetworkCredential(Username, Password, Domain) - }; - return proxy; - } - } - } -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Domain/Features/Configs/Config.ImageConfig.cs b/backend/src/SlideGenerator.Domain/Features/Configs/Config.ImageConfig.cs deleted file mode 100644 index b08104a2..00000000 --- a/backend/src/SlideGenerator.Domain/Features/Configs/Config.ImageConfig.cs +++ /dev/null @@ -1,53 +0,0 @@ -namespace SlideGenerator.Domain.Configs; - -public sealed partial class Config -{ - public sealed class ImageConfig - { - public FaceConfig Face { get; init; } = new(); - public SaliencyConfig Saliency { get; init; } = new(); - - public sealed class FaceConfig - { - /// - /// Minimum face detection confidence score (0-1). Default is 0.7. - /// - public float Confidence { get; init; } = 0.7f; - - /// - /// If true, union all detected faces; otherwise use the best single face. Default is . - /// - public bool UnionAll { get; init; } = false; - - /// - /// Maximum dimension (width or height) for face detection image. - /// If the image is larger, it will be resized maintaining aspect ratio. - /// Default is 1280. - /// - public int MaxDimension { get; init; } = 1280; - } - - public sealed class SaliencyConfig - { - /// - /// Padding ratio for top side of saliency anchor (0-1). Default is 0.0. - /// - public float PaddingTop { get; init; } = 0.0f; - - /// - /// Padding ratio for bottom side of saliency anchor (0-1). Default is 0.0. - /// - public float PaddingBottom { get; init; } = 0.0f; - - /// - /// Padding ratio for left side of saliency anchor (0-1). Default is 0.0. - /// - public float PaddingLeft { get; init; } = 0.0f; - - /// - /// Padding ratio for right side of saliency anchor (0-1). Default is 0.0. - /// - public float PaddingRight { get; init; } = 0.0f; - } - } -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Domain/Features/Configs/Config.JobConfig.cs b/backend/src/SlideGenerator.Domain/Features/Configs/Config.JobConfig.cs deleted file mode 100644 index e571b4fe..00000000 --- a/backend/src/SlideGenerator.Domain/Features/Configs/Config.JobConfig.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace SlideGenerator.Domain.Configs; - -public sealed partial class Config -{ - public sealed class JobConfig - { - public int MaxConcurrentJobs { get; init; } = 5; - } -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Domain/Features/Configs/Config.ServerConfig.cs b/backend/src/SlideGenerator.Domain/Features/Configs/Config.ServerConfig.cs deleted file mode 100644 index 85725ed4..00000000 --- a/backend/src/SlideGenerator.Domain/Features/Configs/Config.ServerConfig.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace SlideGenerator.Domain.Configs; - -public sealed partial class Config -{ - public sealed class ServerConfig - { - public string Host { get; init; } = "127.0.0.1"; - public int Port { get; init; } = 65500; - public bool Debug { get; init; } = false; - } -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Domain/Features/Configs/Config.cs b/backend/src/SlideGenerator.Domain/Features/Configs/Config.cs deleted file mode 100644 index 4b0be301..00000000 --- a/backend/src/SlideGenerator.Domain/Features/Configs/Config.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace SlideGenerator.Domain.Configs; - -public sealed partial class Config -{ - public const string FileName = "backend.config.yaml"; - public const string AppName = "SlideGenerator"; - public const string AppDescription = "Backend server of SlideGenerator application."; - public const string AppUrl = "https://github.com/thnhmai06/SlideGenerator"; - public static readonly string DownloadTempPath = Path.Combine(Path.GetTempPath(), AppName); - public static readonly string DefaultDatabasePath = Path.Combine(AppContext.BaseDirectory, "Jobs.db"); - - public ServerConfig Server { get; init; } = new(); - public DownloadConfig Download { get; init; } = new(); - public JobConfig Job { get; init; } = new(); - public ImageConfig Image { get; init; } = new(); -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Domain/Features/Downloads/Enums/DownloadStatus.cs b/backend/src/SlideGenerator.Domain/Features/Downloads/Enums/DownloadStatus.cs deleted file mode 100644 index 0b65e5af..00000000 --- a/backend/src/SlideGenerator.Domain/Features/Downloads/Enums/DownloadStatus.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace SlideGenerator.Domain.Features.Downloads.Enums; - -public enum DownloadStatus -{ - None, - Created, - Running, - Paused, - Completed, - Failed, - Cancelled -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Domain/Features/Downloads/Events/DownloadCompletedArgs.cs b/backend/src/SlideGenerator.Domain/Features/Downloads/Events/DownloadCompletedArgs.cs deleted file mode 100644 index 5100873a..00000000 --- a/backend/src/SlideGenerator.Domain/Features/Downloads/Events/DownloadCompletedArgs.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace SlideGenerator.Domain.Features.Downloads.Events; - -public class DownloadCompletedArgs(bool success, string fileName, string filePath, Exception? error) : EventArgs -{ - public bool Success { get; } = success; - public string FileName { get; } = fileName; - public string FilePath { get; } = filePath; - public Exception? Error { get; } = error; -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Domain/Features/Downloads/Events/DownloadProgressedArgs.cs b/backend/src/SlideGenerator.Domain/Features/Downloads/Events/DownloadProgressedArgs.cs deleted file mode 100644 index 728c2b4c..00000000 --- a/backend/src/SlideGenerator.Domain/Features/Downloads/Events/DownloadProgressedArgs.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace SlideGenerator.Domain.Features.Downloads.Events; - -public class DownloadProgressedArgs(long bytesReceived, long totalBytes, double progressPercentage) : EventArgs -{ - public long BytesReceived { get; } = bytesReceived; - public long TotalBytes { get; } = totalBytes; - public double ProgressPercentage { get; } = progressPercentage; -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Domain/Features/Downloads/Events/DownloadStartedArgs.cs b/backend/src/SlideGenerator.Domain/Features/Downloads/Events/DownloadStartedArgs.cs deleted file mode 100644 index d6642c1d..00000000 --- a/backend/src/SlideGenerator.Domain/Features/Downloads/Events/DownloadStartedArgs.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace SlideGenerator.Domain.Features.Downloads.Events; - -public class DownloadStartedArgs(string url, string fileName, string filePath, long totalBytes) : EventArgs -{ - public string Url { get; } = url; - public long Size { get; } = totalBytes; - public string FileName { get; } = fileName; - public string FilePath { get; } = filePath; -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Domain/Features/Downloads/IDownloadClient.cs b/backend/src/SlideGenerator.Domain/Features/Downloads/IDownloadClient.cs deleted file mode 100644 index 5fdde3b4..00000000 --- a/backend/src/SlideGenerator.Domain/Features/Downloads/IDownloadClient.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace SlideGenerator.Domain.Features.Downloads; - -/// -/// Abstraction for downloading external resources. -/// -public interface IDownloadClient -{ - /// - /// Downloads a resource to the specified folder. - /// - Task DownloadAsync(Uri uri, DirectoryInfo saveFolder, CancellationToken cancellationToken); -} - -/// -/// Result of a download operation. -/// -public sealed record DownloadResult(bool Success, string? FilePath, string? ErrorMessage); \ No newline at end of file diff --git a/backend/src/SlideGenerator.Domain/Features/Downloads/IDownloadTask.cs b/backend/src/SlideGenerator.Domain/Features/Downloads/IDownloadTask.cs deleted file mode 100644 index 12f1a09d..00000000 --- a/backend/src/SlideGenerator.Domain/Features/Downloads/IDownloadTask.cs +++ /dev/null @@ -1,43 +0,0 @@ -using SlideGenerator.Domain.Features.Downloads.Enums; -using SlideGenerator.Domain.Features.Downloads.Events; - -namespace SlideGenerator.Domain.Features.Downloads; - -public interface IDownloadTask -{ - string Url { get; } - DirectoryInfo SaveFolder { get; init; } - string FileName { get; } - string FilePath { get; } - DownloadStatus Status { get; } - long TotalSize { get; } - long DownloadedSize { get; } - double Progress { get; } - bool IsBusy { get; } - bool IsPaused { get; } - bool IsCancelled { get; } - - event EventHandler? DownloadStartedEvents; - event EventHandler? DownloadProgressedEvents; - event EventHandler? DownloadCompletedEvents; - - /// - /// Starts the download asynchronously. - /// - Task DownloadFileAsync(); - - /// - /// Pauses the download. - /// - void Pause(); - - /// - /// Resumes a paused download. - /// - void Resume(); - - /// - /// Cancels the download. - /// - void Cancel(); -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Domain/Features/IO/IFileSystem.cs b/backend/src/SlideGenerator.Domain/Features/IO/IFileSystem.cs deleted file mode 100644 index 6e99c630..00000000 --- a/backend/src/SlideGenerator.Domain/Features/IO/IFileSystem.cs +++ /dev/null @@ -1,27 +0,0 @@ -namespace SlideGenerator.Domain.Features.IO; - -/// -/// Provides filesystem operations for job orchestration. -/// -public interface IFileSystem -{ - /// - /// Checks whether a file exists. - /// - bool FileExists(string path); - - /// - /// Copies a file to the destination. - /// - void CopyFile(string sourcePath, string destinationPath, bool overwrite); - - /// - /// Deletes a file if it exists. - /// - void DeleteFile(string path); - - /// - /// Ensures a directory exists. - /// - void EnsureDirectory(string path); -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Domain/Features/Images/Enums/ImageCropType.cs b/backend/src/SlideGenerator.Domain/Features/Images/Enums/ImageCropType.cs deleted file mode 100644 index f4a3036f..00000000 --- a/backend/src/SlideGenerator.Domain/Features/Images/Enums/ImageCropType.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System.Text.Json.Serialization; - -namespace SlideGenerator.Domain.Features.Images.Enums; - -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum ImageCropType -{ - Crop, - Fit -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Domain/Features/Images/Enums/ImageRoiType.cs b/backend/src/SlideGenerator.Domain/Features/Images/Enums/ImageRoiType.cs deleted file mode 100644 index 20d6165e..00000000 --- a/backend/src/SlideGenerator.Domain/Features/Images/Enums/ImageRoiType.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System.Text.Json.Serialization; - -namespace SlideGenerator.Domain.Features.Images.Enums; - -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum ImageRoiType -{ - RuleOfThirds, - Prominent, - Center -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Domain/Features/Jobs/Components/JobImageConfig.cs b/backend/src/SlideGenerator.Domain/Features/Jobs/Components/JobImageConfig.cs deleted file mode 100644 index 65d03c00..00000000 --- a/backend/src/SlideGenerator.Domain/Features/Jobs/Components/JobImageConfig.cs +++ /dev/null @@ -1,8 +0,0 @@ -using SlideGenerator.Domain.Features.Images.Enums; - -namespace SlideGenerator.Domain.Features.Jobs.Components; - -/// -/// Configuration for image replacement in slides. -/// -public record JobImageConfig(uint ShapeId, ImageRoiType RoiType, ImageCropType CropType, params string[] Columns); \ No newline at end of file diff --git a/backend/src/SlideGenerator.Domain/Features/Jobs/Components/JobTextConfig.cs b/backend/src/SlideGenerator.Domain/Features/Jobs/Components/JobTextConfig.cs deleted file mode 100644 index 0bdddf57..00000000 --- a/backend/src/SlideGenerator.Domain/Features/Jobs/Components/JobTextConfig.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace SlideGenerator.Domain.Features.Jobs.Components; - -/// -/// Configuration for text replacement in slides. -/// -public record JobTextConfig(string Pattern, params string[] Columns); \ No newline at end of file diff --git a/backend/src/SlideGenerator.Domain/Features/Jobs/Components/PauseSignal.cs b/backend/src/SlideGenerator.Domain/Features/Jobs/Components/PauseSignal.cs deleted file mode 100644 index 4bad7a45..00000000 --- a/backend/src/SlideGenerator.Domain/Features/Jobs/Components/PauseSignal.cs +++ /dev/null @@ -1,44 +0,0 @@ -namespace SlideGenerator.Domain.Features.Jobs.Components; - -/// -/// Cooperative pause controller for job execution. -/// -public sealed class PauseSignal -{ - private volatile TaskCompletionSource? _pauseSource; - - /// - /// Gets a value indicating whether the signal is paused. - /// - public bool IsPaused => _pauseSource != null; - - /// - /// Requests a pause at the next checkpoint. - /// - public void Pause() - { - if (_pauseSource != null) return; - _pauseSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - } - - /// - /// Resumes execution from a paused state. - /// - public void Resume() - { - var source = _pauseSource; - if (source == null) return; - _pauseSource = null; - source.TrySetResult(true); - } - - /// - /// Exits the current execution if paused. - /// - public Task WaitIfPausedAsync(CancellationToken cancellationToken) - { - var source = _pauseSource; - if (source == null) return Task.CompletedTask; - return Task.FromException(new OperationCanceledException("Job paused.")); - } -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Domain/Features/Jobs/Entities/JobGroup.cs b/backend/src/SlideGenerator.Domain/Features/Jobs/Entities/JobGroup.cs deleted file mode 100644 index b7b1a76b..00000000 --- a/backend/src/SlideGenerator.Domain/Features/Jobs/Entities/JobGroup.cs +++ /dev/null @@ -1,187 +0,0 @@ -using System.Collections.Concurrent; -using SlideGenerator.Domain.Features.Jobs.Components; -using SlideGenerator.Domain.Features.Jobs.Enums; -using SlideGenerator.Domain.Features.Jobs.Interfaces; -using SlideGenerator.Domain.Features.Sheets.Interfaces; -using SlideGenerator.Domain.Features.Slides; - -namespace SlideGenerator.Domain.Features.Jobs.Entities; - -/// -/// Represents a group job composed of multiple sheet jobs. -/// -public sealed class JobGroup : IJobGroup -{ - private readonly ConcurrentDictionary _jobs = new(); - - /// - /// Creates a new group job instance; optionally preserves an existing id for restore. - /// - public JobGroup( - ISheetBook workbook, - ITemplatePresentation template, - DirectoryInfo outputFolder, - JobTextConfig[] textConfigs, - JobImageConfig[] imageConfigs, - DateTimeOffset? createdAt = null, - string? id = null) - { - Id = id ?? Guid.NewGuid().ToString(); - Workbook = workbook; - Template = template; - OutputFolder = outputFolder; - TextConfigs = textConfigs; - ImageConfigs = imageConfigs; - CreatedAt = createdAt ?? DateTimeOffset.UtcNow; - } - - /// - /// Gets the creation timestamp for the group. - /// - public DateTimeOffset CreatedAt { get; } - - /// - /// Gets the configured text replacements for the group. - /// - public JobTextConfig[] TextConfigs { get; } - - /// - /// Gets the configured image replacements for the group. - /// - public JobImageConfig[] ImageConfigs { get; } - - /// - /// Gets internal sheet jobs for management purposes. - /// - public IReadOnlyDictionary InternalJobs => _jobs; - - /// - /// Indicates whether any sheet is still active. - /// - public bool IsActive => Status is GroupStatus.Pending or GroupStatus.Running or GroupStatus.Paused; - - /// - public string Id { get; } - - /// - public ISheetBook Workbook { get; } - - /// - public ITemplatePresentation Template { get; } - - /// - public DirectoryInfo OutputFolder { get; } - - /// - public GroupStatus Status { get; private set; } = GroupStatus.Pending; - - /// - public float Progress - { - get - { - if (_jobs.IsEmpty) return 0; - - long totalRows = 0; - long completedRows = 0; - foreach (var job in _jobs.Values) - { - var total = job.TotalRows; - totalRows += total; - completedRows += Math.Min(job.CurrentRow, total); - } - - return totalRows == 0 ? 0 : (float)completedRows / totalRows * 100.0f; - } - } - - /// - public int ErrorCount => _jobs.Values.Sum(j => j.ErrorCount); - - /// - public IReadOnlyDictionary Sheets - { - get - { - var result = new Dictionary(_jobs.Count); - foreach (var kv in _jobs) - result.Add(kv.Key, kv.Value); - return result; - } - } - - /// - public int SheetCount => _jobs.Count; - - /// - /// Adds a new sheet job for the specified worksheet name. - /// - public JobSheet AddJob(string sheetName, string outputPath, string? sheetId = null) - { - if (!Workbook.Worksheets.TryGetValue(sheetName, out var worksheet)) - throw new InvalidOperationException($"Sheet '{sheetName}' not found in workbook."); - - var job = new JobSheet(Id, worksheet, outputPath, TextConfigs, ImageConfigs, sheetId); - _jobs[job.Id] = job; - return job; - } - - /// - /// Removes a sheet job by id. - /// - public bool RemoveJob(string sheetId) - { - return _jobs.TryRemove(sheetId, out _); - } - - /// - /// Sets the status of the group. - /// - public void SetStatus(GroupStatus status) - { - Status = status; - } - - /// - /// Updates the group status based on its sheets. - /// - public void UpdateStatus() - { - var jobs = _jobs.Values; - if (jobs.Count == 0) - { - Status = GroupStatus.Pending; - return; - } - - var hasActive = jobs.Any(j => - j.Status is SheetJobStatus.Pending or SheetJobStatus.Running or SheetJobStatus.Paused); - if (!hasActive) - { - if (jobs.Any(j => j.Status == SheetJobStatus.Failed)) - { - Status = GroupStatus.Failed; - return; - } - - Status = jobs.Any(j => j.Status == SheetJobStatus.Cancelled) - ? GroupStatus.Cancelled - : GroupStatus.Completed; - return; - } - - if (jobs.Any(j => j.Status == SheetJobStatus.Running)) - { - Status = GroupStatus.Running; - return; - } - - if (jobs.Any(j => j.Status == SheetJobStatus.Paused)) - { - Status = GroupStatus.Paused; - return; - } - - Status = GroupStatus.Pending; - } -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Domain/Features/Jobs/Entities/JobSheet.cs b/backend/src/SlideGenerator.Domain/Features/Jobs/Entities/JobSheet.cs deleted file mode 100644 index 1bc82c08..00000000 --- a/backend/src/SlideGenerator.Domain/Features/Jobs/Entities/JobSheet.cs +++ /dev/null @@ -1,164 +0,0 @@ -using SlideGenerator.Domain.Features.Jobs.Components; -using SlideGenerator.Domain.Features.Jobs.Enums; -using SlideGenerator.Domain.Features.Jobs.Interfaces; -using SlideGenerator.Domain.Features.Sheets.Interfaces; - -namespace SlideGenerator.Domain.Features.Jobs.Entities; - -/// -/// Represents a single worksheet job that generates one output presentation. -/// -public sealed class JobSheet : IJobSheet -{ - private readonly PauseSignal _pauseSignal = new(); - - /// - /// Creates a new sheet job instance; optionally preserves an existing id for restore. - /// - public JobSheet( - string groupId, - ISheet worksheet, - string outputPath, - JobTextConfig[] textConfigs, - JobImageConfig[] imageConfigs, - string? id = null) - { - Id = id ?? Guid.NewGuid().ToString(); - GroupId = groupId; - Worksheet = worksheet; - OutputPath = outputPath; - TextConfigs = textConfigs; - ImageConfigs = imageConfigs; - } - - /// - /// Gets the worksheet backing this job. - /// - public ISheet Worksheet { get; } - - /// - /// Gets the configured text replacements for this sheet. - /// - public JobTextConfig[] TextConfigs { get; } - - /// - /// Gets the configured image replacements for this sheet. - /// - public JobImageConfig[] ImageConfigs { get; } - - /// - /// Gets the row index (1-based) that should be processed next. - /// - public int NextRowIndex => CurrentRow + 1; - - /// - /// Gets the cancellation token source for this job. - /// - public CancellationTokenSource CancellationTokenSource { get; } = new(); - - /// - /// Gets a value indicating whether this job is currently executing. - /// - public bool IsExecuting { get; private set; } - - /// - /// Gets the Hangfire job id associated with this sheet execution. - /// - public string? HangfireJobId { get; set; } - - /// - public string Id { get; } - - /// - public string GroupId { get; } - - /// - public string SheetName => Worksheet.Name; - - /// - public string OutputPath { get; } - - /// - public SheetJobStatus Status { get; private set; } = SheetJobStatus.Pending; - - /// - public string? ErrorMessage { get; private set; } - - /// - public int CurrentRow { get; private set; } - - /// - public int TotalRows => Worksheet.RowCount; - - /// - public float Progress => TotalRows == 0 ? 0 : (float)CurrentRow / TotalRows * 100.0f; - - /// - public int ErrorCount { get; private set; } - - /// - /// Sets the job status and optional message. - /// - public void SetStatus(SheetJobStatus status, string? message = null) - { - Status = status; - ErrorMessage = message; - } - - /// - /// Updates the current row for progress tracking. - /// - public void UpdateProgress(int currentRow) - { - CurrentRow = Math.Clamp(currentRow, 0, TotalRows); - } - - /// - /// Registers an error for a specific row. - /// - public void RegisterRowError(int rowIndex, string message) - { - ErrorCount++; - } - - /// - /// Restores the error count from persisted state. - /// - public void RestoreErrorCount(int count) - { - ErrorCount = Math.Max(0, count); - } - - /// - /// Marks the job as executing or idle. - /// - public void MarkExecuting(bool isExecuting) - { - IsExecuting = isExecuting; - } - - /// - /// Requests the job to pause on the next checkpoint. - /// - public void Pause() - { - _pauseSignal.Pause(); - SetStatus(SheetJobStatus.Paused); - } - - /// - /// Resumes the job from a paused state. - /// - public void Resume() - { - _pauseSignal.Resume(); - } - - /// - /// Waits if the job is currently paused. - /// - public Task WaitIfPausedAsync(CancellationToken cancellationToken) - { - return _pauseSignal.WaitIfPausedAsync(cancellationToken); - } -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Domain/Features/Jobs/Enums/JobGroupStatus.cs b/backend/src/SlideGenerator.Domain/Features/Jobs/Enums/JobGroupStatus.cs deleted file mode 100644 index 146fd0cd..00000000 --- a/backend/src/SlideGenerator.Domain/Features/Jobs/Enums/JobGroupStatus.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System.Text.Json.Serialization; - -namespace SlideGenerator.Domain.Features.Jobs.Enums; - -/// -/// Represents the lifecycle status of a group job. -/// -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum GroupStatus -{ - Pending, - Running, - Paused, - Completed, - Failed, - Cancelled -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Domain/Features/Jobs/Enums/JobSheetStatus.cs b/backend/src/SlideGenerator.Domain/Features/Jobs/Enums/JobSheetStatus.cs deleted file mode 100644 index 6635a100..00000000 --- a/backend/src/SlideGenerator.Domain/Features/Jobs/Enums/JobSheetStatus.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System.Text.Json.Serialization; - -namespace SlideGenerator.Domain.Features.Jobs.Enums; - -/// -/// Represents the lifecycle status of a sheet job. -/// -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum SheetJobStatus -{ - Pending, - Running, - Paused, - Completed, - Failed, - Cancelled -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Domain/Features/Jobs/Enums/JobState.cs b/backend/src/SlideGenerator.Domain/Features/Jobs/Enums/JobState.cs deleted file mode 100644 index 9b59ac7b..00000000 --- a/backend/src/SlideGenerator.Domain/Features/Jobs/Enums/JobState.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System.Text.Json.Serialization; - -namespace SlideGenerator.Domain.Features.Jobs.Enums; - -/// -/// Represents the lifecycle status of a job (group or sheet). -/// -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum JobState -{ - Pending, - Processing, - Paused, - Done, - Cancelled, - Error -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Domain/Features/Jobs/Enums/JobType.cs b/backend/src/SlideGenerator.Domain/Features/Jobs/Enums/JobType.cs deleted file mode 100644 index e33426f9..00000000 --- a/backend/src/SlideGenerator.Domain/Features/Jobs/Enums/JobType.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System.Text.Json.Serialization; - -namespace SlideGenerator.Domain.Features.Jobs.Enums; - -/// -/// Represents the job type. -/// -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum JobType -{ - Group, - Sheet -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Domain/Features/Jobs/Interfaces/IJobEventPublisher.cs b/backend/src/SlideGenerator.Domain/Features/Jobs/Interfaces/IJobEventPublisher.cs deleted file mode 100644 index fabf18d2..00000000 --- a/backend/src/SlideGenerator.Domain/Features/Jobs/Interfaces/IJobEventPublisher.cs +++ /dev/null @@ -1,14 +0,0 @@ -using SlideGenerator.Domain.Features.Jobs.Notifications; - -namespace SlideGenerator.Domain.Features.Jobs.Interfaces; - -/// -/// Publishes realtime job events to subscribers. -/// -public interface IJobEventPublisher -{ - /// - /// Publishes a job event. - /// - Task PublishAsync(JobEvent notification, CancellationToken cancellationToken); -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Domain/Features/Jobs/Interfaces/IJobGroup.cs b/backend/src/SlideGenerator.Domain/Features/Jobs/Interfaces/IJobGroup.cs deleted file mode 100644 index 9cd7c36a..00000000 --- a/backend/src/SlideGenerator.Domain/Features/Jobs/Interfaces/IJobGroup.cs +++ /dev/null @@ -1,56 +0,0 @@ -using SlideGenerator.Domain.Features.Jobs.Enums; -using SlideGenerator.Domain.Features.Sheets.Interfaces; -using SlideGenerator.Domain.Features.Slides; - -namespace SlideGenerator.Domain.Features.Jobs.Interfaces; - -/// -/// Exposes a read-only view of a group job. -/// -public interface IJobGroup -{ - /// - /// Unique identifier for the group job. - /// - string Id { get; } - - /// - /// Workbook that provides sheet data for the group. - /// - ISheetBook Workbook { get; } - - /// - /// Template presentation used for slide generation. - /// - ITemplatePresentation Template { get; } - - /// - /// Output folder for generated presentations. - /// - DirectoryInfo OutputFolder { get; } - - /// - /// Current group lifecycle status. - /// - GroupStatus Status { get; } - - /// - /// Aggregate progress across all sheet jobs (0-100). - /// - float Progress { get; } - - /// - /// Total number of errors across sheets. - /// - int ErrorCount { get; } - - /// - /// Sheet jobs belonging to this group (id -> job). - /// - IReadOnlyDictionary Sheets { get; } - - /// - /// Number of sheets in this group. - /// - int SheetCount { get; } -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Domain/Features/Jobs/Interfaces/IJobSheet.cs b/backend/src/SlideGenerator.Domain/Features/Jobs/Interfaces/IJobSheet.cs deleted file mode 100644 index db3fa619..00000000 --- a/backend/src/SlideGenerator.Domain/Features/Jobs/Interfaces/IJobSheet.cs +++ /dev/null @@ -1,64 +0,0 @@ -using SlideGenerator.Domain.Features.Jobs.Enums; - -namespace SlideGenerator.Domain.Features.Jobs.Interfaces; - -/// -/// Exposes a read-only view of a sheet job. -/// -public interface IJobSheet -{ - /// - /// Unique identifier for the sheet job. - /// - string Id { get; } - - /// - /// Parent group identifier. - /// - string GroupId { get; } - - /// - /// Source worksheet name. - /// - string SheetName { get; } - - /// - /// Output file path for the generated presentation. - /// - string OutputPath { get; } - - /// - /// Current sheet job lifecycle status. - /// - SheetJobStatus Status { get; } - - /// - /// Current processed row index (1-based). - /// - int CurrentRow { get; } - - /// - /// Total rows available in the worksheet. - /// - int TotalRows { get; } - - /// - /// Progress percentage (0-100). - /// - float Progress { get; } - - /// - /// Number of errors encountered so far. - /// - int ErrorCount { get; } - - /// - /// Error message for fatal failures, if any. - /// - string? ErrorMessage { get; } - - /// - /// Hangfire background job id, if queued. - /// - string? HangfireJobId { get; } -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Domain/Features/Jobs/Interfaces/IJobStateStore.cs b/backend/src/SlideGenerator.Domain/Features/Jobs/Interfaces/IJobStateStore.cs deleted file mode 100644 index 50d43847..00000000 --- a/backend/src/SlideGenerator.Domain/Features/Jobs/Interfaces/IJobStateStore.cs +++ /dev/null @@ -1,69 +0,0 @@ -using SlideGenerator.Domain.Features.Jobs.States; - -namespace SlideGenerator.Domain.Features.Jobs.Interfaces; - -/// -/// Persists and restores job state for resume. -/// -public interface IJobStateStore -{ - /// - /// Persists group state. - /// - Task SaveGroupAsync(GroupJobState state, CancellationToken cancellationToken); - - /// - /// Persists sheet state. - /// - Task SaveSheetAsync(SheetJobState state, CancellationToken cancellationToken); - - /// - /// Retrieves a group state by id. - /// - Task GetGroupAsync(string groupId, CancellationToken cancellationToken); - - /// - /// Retrieves a sheet state by id. - /// - Task GetSheetAsync(string sheetId, CancellationToken cancellationToken); - - /// - /// Gets active group states. - /// - Task> GetActiveGroupsAsync(CancellationToken cancellationToken); - - /// - /// Gets all group states (active + completed). - /// - Task> GetAllGroupsAsync(CancellationToken cancellationToken); - - /// - /// Appends a log entry for a job. - /// - Task AppendJobLogAsync(JobLogEntry entry, CancellationToken cancellationToken); - - /// - /// Appends multiple log entries for a job. - /// - Task AppendJobLogsAsync(IReadOnlyCollection entries, CancellationToken cancellationToken); - - /// - /// Gets all log entries for a job. - /// - Task> GetJobLogsAsync(string jobId, CancellationToken cancellationToken); - - /// - /// Gets sheet states for a group. - /// - Task> GetSheetsByGroupAsync(string groupId, CancellationToken cancellationToken); - - /// - /// Removes a group state and its sheets. - /// - Task RemoveGroupAsync(string groupId, CancellationToken cancellationToken); - - /// - /// Removes a sheet state. - /// - Task RemoveSheetAsync(string sheetId, CancellationToken cancellationToken); -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Domain/Features/Jobs/Notifications/JobEvent.cs b/backend/src/SlideGenerator.Domain/Features/Jobs/Notifications/JobEvent.cs deleted file mode 100644 index 960ad7bc..00000000 --- a/backend/src/SlideGenerator.Domain/Features/Jobs/Notifications/JobEvent.cs +++ /dev/null @@ -1,22 +0,0 @@ -namespace SlideGenerator.Domain.Features.Jobs.Notifications; - -/// -/// Represents a realtime job event. -/// -public sealed record JobEvent( - string JobId, - JobEventScope Scope, - DateTimeOffset Timestamp, - string Level, - string Message, - IReadOnlyDictionary? Data = null); - -/// -/// Indicates which job scope the event belongs to. -/// -public enum JobEventScope -{ - Group, - Sheet, - System -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Domain/Features/Jobs/States/GroupJobState.cs b/backend/src/SlideGenerator.Domain/Features/Jobs/States/GroupJobState.cs deleted file mode 100644 index 420fea54..00000000 --- a/backend/src/SlideGenerator.Domain/Features/Jobs/States/GroupJobState.cs +++ /dev/null @@ -1,16 +0,0 @@ -using SlideGenerator.Domain.Features.Jobs.Enums; - -namespace SlideGenerator.Domain.Features.Jobs.States; - -/// -/// Persisted state for a group job. -/// -public sealed record GroupJobState( - string Id, - string WorkbookPath, - string TemplatePath, - string OutputFolderPath, - GroupStatus Status, - DateTimeOffset CreatedAt, - IReadOnlyList SheetIds, - int ErrorCount); \ No newline at end of file diff --git a/backend/src/SlideGenerator.Domain/Features/Jobs/States/JobLogEntry.cs b/backend/src/SlideGenerator.Domain/Features/Jobs/States/JobLogEntry.cs deleted file mode 100644 index d7f935a0..00000000 --- a/backend/src/SlideGenerator.Domain/Features/Jobs/States/JobLogEntry.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace SlideGenerator.Domain.Features.Jobs.States; - -/// -/// Persisted log entry for a sheet job. -/// -public sealed record JobLogEntry( - string JobId, - DateTimeOffset Timestamp, - string Level, - string Message, - IReadOnlyDictionary? Data = null); \ No newline at end of file diff --git a/backend/src/SlideGenerator.Domain/Features/Jobs/States/SheetJobState.cs b/backend/src/SlideGenerator.Domain/Features/Jobs/States/SheetJobState.cs deleted file mode 100644 index ee2b5307..00000000 --- a/backend/src/SlideGenerator.Domain/Features/Jobs/States/SheetJobState.cs +++ /dev/null @@ -1,20 +0,0 @@ -using SlideGenerator.Domain.Features.Jobs.Components; -using SlideGenerator.Domain.Features.Jobs.Enums; - -namespace SlideGenerator.Domain.Features.Jobs.States; - -/// -/// Persisted state for a sheet job. -/// -public sealed record SheetJobState( - string Id, - string GroupId, - string SheetName, - string OutputPath, - SheetJobStatus Status, - int NextRowIndex, - int TotalRows, - int ErrorCount, - string? ErrorMessage, - JobTextConfig[] TextConfigs, - JobImageConfig[] ImageConfigs); \ No newline at end of file diff --git a/backend/src/SlideGenerator.Domain/Features/Sheets/Interfaces/ISheet.cs b/backend/src/SlideGenerator.Domain/Features/Sheets/Interfaces/ISheet.cs deleted file mode 100644 index 7f6a5efd..00000000 --- a/backend/src/SlideGenerator.Domain/Features/Sheets/Interfaces/ISheet.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace SlideGenerator.Domain.Features.Sheets.Interfaces; - -/// -/// Represents a worksheet abstraction. -/// -public interface ISheet -{ - string Name { get; } - IReadOnlyList Headers { get; } - int RowCount { get; } - Dictionary GetRow(int rowNumber); - List> GetAllRows(); -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Domain/Features/Sheets/Interfaces/ISheetBook.cs b/backend/src/SlideGenerator.Domain/Features/Sheets/Interfaces/ISheetBook.cs deleted file mode 100644 index 769a99ff..00000000 --- a/backend/src/SlideGenerator.Domain/Features/Sheets/Interfaces/ISheetBook.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace SlideGenerator.Domain.Features.Sheets.Interfaces; - -/// -/// Represents an opened workbook. -/// -public interface ISheetBook : IDisposable -{ - string FilePath { get; } - string? Name { get; } - IReadOnlyDictionary Worksheets { get; } - IReadOnlyDictionary GetSheetsInfo(); -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Domain/Features/Slides/Components/ImagePreview.cs b/backend/src/SlideGenerator.Domain/Features/Slides/Components/ImagePreview.cs deleted file mode 100644 index b3565c28..00000000 --- a/backend/src/SlideGenerator.Domain/Features/Slides/Components/ImagePreview.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace SlideGenerator.Domain.Features.Slides.Components; - -/// -/// Represents raw shape image data from a presentation. -/// -public record ImagePreview(string Name, byte[] Image); \ No newline at end of file diff --git a/backend/src/SlideGenerator.Domain/Features/Slides/Components/ShapeInfo.cs b/backend/src/SlideGenerator.Domain/Features/Slides/Components/ShapeInfo.cs deleted file mode 100644 index 0e41c309..00000000 --- a/backend/src/SlideGenerator.Domain/Features/Slides/Components/ShapeInfo.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace SlideGenerator.Domain.Features.Slides.Components; - -/// -/// Represents metadata for a slide shape. -/// -public sealed record ShapeInfo(uint Id, string Name, string Kind, bool IsImage); \ No newline at end of file diff --git a/backend/src/SlideGenerator.Domain/Features/Slides/ITemplatePresentation.cs b/backend/src/SlideGenerator.Domain/Features/Slides/ITemplatePresentation.cs deleted file mode 100644 index 4e08c487..00000000 --- a/backend/src/SlideGenerator.Domain/Features/Slides/ITemplatePresentation.cs +++ /dev/null @@ -1,15 +0,0 @@ -using SlideGenerator.Domain.Features.Slides.Components; - -namespace SlideGenerator.Domain.Features.Slides; - -/// -/// Represents a template presentation. -/// -public interface ITemplatePresentation : IDisposable -{ - string FilePath { get; } - int SlideCount { get; } - Dictionary GetAllImageShapes(); - IReadOnlyList GetAllShapes(); - IReadOnlyCollection GetAllTextPlaceholders(); -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Domain/Features/Slides/IWorkingPresentation.cs b/backend/src/SlideGenerator.Domain/Features/Slides/IWorkingPresentation.cs deleted file mode 100644 index 4c516b28..00000000 --- a/backend/src/SlideGenerator.Domain/Features/Slides/IWorkingPresentation.cs +++ /dev/null @@ -1,28 +0,0 @@ -namespace SlideGenerator.Domain.Features.Slides; - -/// -/// Represents a working presentation for slide generation. -/// -public interface IWorkingPresentation : IDisposable -{ - /// - /// Gets the file path of the presentation. - /// - string FilePath { get; } - - /// - /// Gets the number of slides in the presentation. - /// - int SlideCount { get; } - - /// - /// Saves the presentation. - /// - void Save(); - - /// - /// Removes the slide at the specified position. - /// - /// The slide position/index (1-based) - void RemoveSlide(int position); -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Domain/SlideGenerator.Domain.csproj b/backend/src/SlideGenerator.Domain/SlideGenerator.Domain.csproj deleted file mode 100644 index 68cc8af8..00000000 --- a/backend/src/SlideGenerator.Domain/SlideGenerator.Domain.csproj +++ /dev/null @@ -1,12 +0,0 @@ - - - - net10.0 - enable - enable - true - GPL-3.0-only - $(NoWarn);1591 - - - diff --git a/backend/src/SlideGenerator.Framework b/backend/src/SlideGenerator.Framework index e6d033ea..8f026932 160000 --- a/backend/src/SlideGenerator.Framework +++ b/backend/src/SlideGenerator.Framework @@ -1 +1 @@ -Subproject commit e6d033ea965e06ac4721197da49a483313733f71 +Subproject commit 8f026932fe40e17c49863017a47252e4c35ddfea diff --git a/backend/src/SlideGenerator.Generating/Models/GenerateSlidesRequest.cs b/backend/src/SlideGenerator.Generating/Models/GenerateSlidesRequest.cs new file mode 100644 index 00000000..10406811 --- /dev/null +++ b/backend/src/SlideGenerator.Generating/Models/GenerateSlidesRequest.cs @@ -0,0 +1,16 @@ +namespace SlideGenerator.Generating.Models; + +/// +/// Represents a generation request payload consumed by jobs endpoint. +/// +/// Spreadsheet source information. +/// Sheet-to-template-slide mapping. +/// Text replacement bindings. +/// Image replacement bindings. +/// Save folder for generated presentations. +public sealed record GenerateSlidesRequest( + SheetConfig Sheet, + IReadOnlyDictionary TemplateMap, + IReadOnlyList TextConfigs, + IReadOnlyList ImageConfigs, + string SaveFolder); \ No newline at end of file diff --git a/backend/src/SlideGenerator.Generating/Models/ImageConfig.cs b/backend/src/SlideGenerator.Generating/Models/ImageConfig.cs new file mode 100644 index 00000000..f981dca4 --- /dev/null +++ b/backend/src/SlideGenerator.Generating/Models/ImageConfig.cs @@ -0,0 +1,11 @@ +using SlideGenerator.Framework.Image.Models.Roi; + +namespace SlideGenerator.Generating.Models; + +/// +/// Represents an image binding configuration for replacement. +/// +/// Target shape identifier in template slide. +/// Candidate columns used to resolve image source value. +/// ROI mode used for image crop and placement. +public sealed record ImageConfig(uint ShapeId, IReadOnlyList Columns, RoiType RoiType); \ No newline at end of file diff --git a/backend/src/SlideGenerator.Generating/Models/SheetConfig.cs b/backend/src/SlideGenerator.Generating/Models/SheetConfig.cs new file mode 100644 index 00000000..2f87f05b --- /dev/null +++ b/backend/src/SlideGenerator.Generating/Models/SheetConfig.cs @@ -0,0 +1,8 @@ +namespace SlideGenerator.Generating.Models; + +/// +/// Represents spreadsheet input source and optional selected sheets. +/// +/// Primary spreadsheet file path. +/// Selected sheets to process ( means all sheets). +public sealed record SheetConfig(string FilePath, IReadOnlyList? SelectedSheets); diff --git a/backend/src/SlideGenerator.Generating/Models/TemplateSlide.cs b/backend/src/SlideGenerator.Generating/Models/TemplateSlide.cs new file mode 100644 index 00000000..c6246786 --- /dev/null +++ b/backend/src/SlideGenerator.Generating/Models/TemplateSlide.cs @@ -0,0 +1,8 @@ +namespace SlideGenerator.Generating.Models; + +/// +/// Represents a template source used for slide generation. +/// +/// Absolute or relative path to template presentation file. +/// 1-based slide index in template presentation. +public sealed record TemplateSlide(string FilePath, int Index); \ No newline at end of file diff --git a/backend/src/SlideGenerator.Generating/Models/TextConfig.cs b/backend/src/SlideGenerator.Generating/Models/TextConfig.cs new file mode 100644 index 00000000..16ec9d54 --- /dev/null +++ b/backend/src/SlideGenerator.Generating/Models/TextConfig.cs @@ -0,0 +1,8 @@ +namespace SlideGenerator.Generating.Models; + +/// +/// Represents a text binding configuration for replacement. +/// +/// Placeholder token to replace in slide content. +/// Candidate columns used to resolve replacement value. +public sealed record TextConfig(string Placeholder, IReadOnlyList Columns); \ No newline at end of file diff --git a/backend/src/SlideGenerator.Generating/Services/DownloadService.cs b/backend/src/SlideGenerator.Generating/Services/DownloadService.cs new file mode 100644 index 00000000..cb4eeef6 --- /dev/null +++ b/backend/src/SlideGenerator.Generating/Services/DownloadService.cs @@ -0,0 +1,64 @@ +using Downloader; +using SlideGenerator.Configs.Contracts; +using SlideGenerator.Configs.Entities; + +namespace SlideGenerator.Generating.Services; + +/// +/// Remote file downloader. +/// +public sealed class DownloadService +{ + private readonly Config _config; + + /// + /// Initializes download service with config manager. + /// + /// Read-only configuration manager. + public DownloadService(IConfigProvider configProvider) + { + _config = configProvider.Current; + } + + /// + /// Downloads a remote resource and returns bytes. + /// + /// Remote URI. + /// Cancellation token. + public async Task DownloadAsync(Uri uri, CancellationToken cancellationToken) + { + var saveFolder = Path.GetFullPath(_config.Download.SaveFolder); + Directory.CreateDirectory(saveFolder); + + var fileName = Path.GetFileName(uri.LocalPath); + if (string.IsNullOrWhiteSpace(fileName)) + fileName = $"download_{Guid.NewGuid():N}.bin"; + + var destinationPath = Path.Combine(saveFolder, $"{Guid.NewGuid():N}_{fileName}"); + + var configuration = new DownloadConfiguration + { + ChunkCount = Math.Max(1, _config.Download.MaxChunks), + MaximumBytesPerSecond = Math.Max(0, _config.Download.LimitBytesPerSecond), + MaxTryAgainOnFailure = Math.Max(0, _config.Download.Retry.MaxRetries), + Timeout = Math.Max(1, _config.Download.Retry.Timeout * 1000), + RequestConfiguration = + { + Proxy = _config.Download.Proxy.GetWebProxy() + } + }; + + var downloader = new Downloader.DownloadService(configuration); + await downloader.DownloadFileTaskAsync(uri.ToString(), destinationPath, cancellationToken).ConfigureAwait(false); + + try + { + return await File.ReadAllBytesAsync(destinationPath, cancellationToken).ConfigureAwait(false); + } + finally + { + if (_config.Download.DeleteAfterDownload && File.Exists(destinationPath)) + File.Delete(destinationPath); + } + } +} diff --git a/backend/src/SlideGenerator.Generating/Services/FaceDetectorModelManager.cs b/backend/src/SlideGenerator.Generating/Services/FaceDetectorModelManager.cs new file mode 100644 index 00000000..03e1b2a2 --- /dev/null +++ b/backend/src/SlideGenerator.Generating/Services/FaceDetectorModelManager.cs @@ -0,0 +1,120 @@ +using System.Collections.Concurrent; +using SlideGenerator.Framework.Image.Entities.FaceDetection; +using SlideGenerator.Framework.Image.Models.FaceDetection; + +namespace SlideGenerator.Generating.Services; + +/// +/// Manages face detector model selection and lifecycle at runtime. +/// +public sealed class FaceDetectorModelManager : IAsyncDisposable +{ + private bool _disposed; + private readonly ConcurrentDictionary _models = new(); + + /// + /// Gets current model key. + /// + public FaceDetectorModelKey CurrentModelKey { get; private set; } = FaceDetectorModelKey.YuNet; + + /// + /// Selects current model key at runtime. + /// + /// Model key to select. + public void SelectModel(FaceDetectorModelKey modelKey) + { + ThrowIfDisposed(); + CurrentModelKey = modelKey; + } + + /// + /// Gets current model instance and ensures it is initialized. + /// + public async Task GetCurrentModelAsync() + { + ThrowIfDisposed(); + var model = GetOrAddModel(CurrentModelKey); + if (model.IsModelAvailable) return model; + + var initialized = await model.InitAsync().ConfigureAwait(false); + return initialized + ? model + : throw new InvalidOperationException($"Model '{CurrentModelKey}' could not be initialized."); + } + + /// + /// Add and Initializes model by key. + /// + /// Model key to add and initialize. + public async Task InitializeAsync(FaceDetectorModelKey modelKey) + { + ThrowIfDisposed(); + var model = GetOrAddModel(modelKey); + return await model.InitAsync().ConfigureAwait(false); + } + + /// + /// De-initializes model by key. + /// + /// Model key to de-initialize. + /// if the model was successfully de-initialized; otherwise, . + public async Task DeInitializeAsync(FaceDetectorModelKey modelKey) + { + ThrowIfDisposed(); + if (!_models.TryGetValue(modelKey, out var model)) return false; + return await model.DeInitAsync().ConfigureAwait(false); + } + + /// + /// Gets a collection of all supported . + /// + /// A collection of values representing the supported model keys for the . + public static ICollection GetSupportedModelKeys() + { + return Enum.GetValues(); + } + + /// + /// Gets a collection of keys that identify the models currently added to the face detector. + /// + /// A collection of objects representing the keys of the added models. + /// Thrown if the manager has been disposed. + public ICollection GetAddedModelKeys() + { + ThrowIfDisposed(); + return _models.Keys; + } + + public ValueTask DisposeAsync() + { + if (_disposed) + return ValueTask.CompletedTask; + + _disposed = true; + + foreach (var key in _models.Keys) + if (_models.TryRemove(key, out var model)) + model.Dispose(); + + return ValueTask.CompletedTask; + } + + private FaceDetectorModel GetOrAddModel(FaceDetectorModelKey modelKey) + { + return _models.GetOrAdd(modelKey, AddModel); + } + + private static FaceDetectorModel AddModel(FaceDetectorModelKey modelKey) + { + return modelKey switch + { + FaceDetectorModelKey.YuNet => new YuNetModel(), + _ => throw new NotSupportedException($"Face detector model '{modelKey}' is not supported.") + }; + } + + private void ThrowIfDisposed() + { + ObjectDisposedException.ThrowIf(_disposed, this); + } +} diff --git a/backend/src/SlideGenerator.Generating/Services/GenerateService.cs b/backend/src/SlideGenerator.Generating/Services/GenerateService.cs new file mode 100644 index 00000000..4fa5ad5d --- /dev/null +++ b/backend/src/SlideGenerator.Generating/Services/GenerateService.cs @@ -0,0 +1,232 @@ +using System.Drawing; +using ClosedXML.Excel; +using DocumentFormat.OpenXml.Packaging; +using ImageMagick; +using SlideGenerator.Framework.Image.Factory; +using SlideGenerator.Framework.Image.Models.FaceDetection; +using SlideGenerator.Framework.Image.Models.Roi; +using SlideGenerator.Framework.Image.Services; +using SlideGenerator.Framework.Sheet.Services; +using SlideGenerator.Framework.Slide.Services; +using SlideGenerator.Generating.Models; + +namespace SlideGenerator.Generating.Services; + +/// +/// Executes per-row slide generation operations, including text and image replacement. +/// +public sealed class GenerateService : IAsyncDisposable +{ + /// + /// Face detector manager used to select and control detector model lifecycle. + /// + private readonly FaceDetectorModelManager _faceDetectorManager; + + /// + /// Service for downloading remote image sources. + /// + private readonly DownloadService _downloadService; + + /// + /// ROI options used by all ROI calculators. + /// + private readonly RoiOptions _roiOptions = new(); + + /// + /// Initializes runtime dependencies used for generation. + /// + /// Face detector manager. + /// Download service. + public GenerateService(FaceDetectorModelManager faceDetectorManager, DownloadService downloadService) + { + _faceDetectorManager = faceDetectorManager; + _downloadService = downloadService; + } + + /// + /// Disposes runtime resources allocated for generation. + /// + public ValueTask DisposeAsync() + { + return _faceDetectorManager.DisposeAsync(); + } + + /// + /// Initializes face detector model by key. + /// + /// Model key to initialize. + public Task InitializeFaceDetectorAsync(FaceDetectorModelKey modelKey) + { + return _faceDetectorManager.InitializeAsync(modelKey); + } + + /// + /// De-initializes face detector model by key. + /// + /// Model key to de-initialize. + public Task DeInitializeFaceDetectorAsync(FaceDetectorModelKey modelKey) + { + return _faceDetectorManager.DeInitializeAsync(modelKey); + } + + /// + /// Selects a face detector model manually. + /// + public Task SelectFaceDetectorModelAsync(FaceDetectorModelKey modelKey) + { + _faceDetectorManager.SelectModel(modelKey); + return Task.CompletedTask; + } + + /// + /// Clones a template slide and applies row-bound text and image data to the new slide. + /// + /// Target presentation document. + /// Template slide relationship identifier. + /// Source worksheet data range. + /// 1-based row index in worksheet data body. + /// Text binding configuration list. + /// Image binding configuration list. + /// Cancellation token. + public async Task ProcessRowAsync( + PresentationDocument document, + string relationshipId, + IXLRange? usedRange, + int rowIndex, + IReadOnlyList textConfig, + IReadOnlyList imageConfig, + CancellationToken cancellationToken) + { + var data = GetRowData(usedRange, rowIndex); + var newSlide = XmlPresentationService.CloneSlide(document, relationshipId); + await ApplyTextAsync(newSlide, data, textConfig); + await ApplyImageAsync(newSlide, data, imageConfig, cancellationToken); + } + + private static IReadOnlyDictionary GetRowData(IXLRange? usedRange, int rowIndex) + { + return usedRange == null + ? new Dictionary() + : WorksheetService.GetRowContent(usedRange, rowIndex); + } + + private static async Task ApplyTextAsync( + SlidePart slidePart, + IReadOnlyDictionary rowData, + IReadOnlyList config) + { + var replacements = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var binding in config) + foreach (var column in binding.Columns) + { + if (!rowData.TryGetValue(column, out var value) || string.IsNullOrWhiteSpace(value)) continue; + replacements[binding.Placeholder] = value; + break; + } + + if (replacements.Count == 0) return; + await TextReplacer.ReplaceTextAsync(slidePart, replacements); + } + + private async Task ApplyImageAsync( + SlidePart slidePart, + IReadOnlyDictionary rowData, + IReadOnlyList imageBindings, + CancellationToken cancellationToken) + { + foreach (var binding in imageBindings) + { + var source = ResolveValue(rowData, binding.Columns); + if (string.IsNullOrWhiteSpace(source)) continue; + + var sourceBytes = await LoadImageBytesAsync(source, cancellationToken); + if (sourceBytes.Length == 0) continue; + + var picture = ShapeService.FindPictureById(slidePart, binding.ShapeId); + if (picture != null) + { + var targetSize = ShapeService.GetPictureSize(picture); + var imageBytes = + await ProcessImageBytesAsync(sourceBytes, targetSize, binding.RoiType, cancellationToken); + using var stream = new MemoryStream(imageBytes, false); + ImageReplacer.ReplaceImage(slidePart, picture, stream); + continue; + } + + var shape = ShapeService.FindShapeById(slidePart, binding.ShapeId); + if (shape != null) + { + var targetSize = ShapeService.GetShapeSize(shape); + var imageBytes = + await ProcessImageBytesAsync(sourceBytes, targetSize, binding.RoiType, cancellationToken); + using var stream = new MemoryStream(imageBytes, false); + ImageReplacer.ReplaceImage(slidePart, shape, stream); + } + } + } + + private async Task ProcessImageBytesAsync( + byte[] sourceBytes, + Size targetSize, + RoiType roiType, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + using var image = new MagickImage(sourceBytes); + var mat = ConvertingService.ConvertImageToMat(image); + if (mat == null || mat.IsEmpty) return sourceBytes; + + try + { + var roiFactory = new RoiFactory { Options = _roiOptions }; + if (roiType == RoiType.RuleOfThirds) + { + var faceDetector = await _faceDetectorManager + .GetCurrentModelAsync() + .ConfigureAwait(false); + + roiFactory = new RoiFactory + { + Options = _roiOptions, + FaceDetector = faceDetector + }; + } + + var calculator = roiFactory.GetCalculator(roiType); + var cropRect = await calculator.CalculateRoiAsync(mat, targetSize).ConfigureAwait(false); + ManipulatingService.Crop(ref mat, cropRect); + ManipulatingService.Resize(ref mat, targetSize); + return ConvertingService.ConvertMatToImage(mat); + } + finally + { + mat.Dispose(); + } + } + + private async Task LoadImageBytesAsync(string source, CancellationToken cancellationToken) + { + if (Uri.TryCreate(source, UriKind.Absolute, out var uri) && + (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps)) + return await _downloadService.DownloadAsync(uri, cancellationToken); + + var filePath = Path.GetFullPath(source); + return File.Exists(filePath) ? await File.ReadAllBytesAsync(filePath, cancellationToken) : []; + } + + private static string ResolveValue( + IReadOnlyDictionary rowData, + IReadOnlyList columns) + { + foreach (var column in columns) + { + if (string.IsNullOrWhiteSpace(column)) continue; + if (!rowData.TryGetValue(column, out var value)) continue; + if (!string.IsNullOrWhiteSpace(value)) return value; + } + + return string.Empty; + } + +} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Generating/Services/ValidationService.cs b/backend/src/SlideGenerator.Generating/Services/ValidationService.cs new file mode 100644 index 00000000..a4ca86a6 --- /dev/null +++ b/backend/src/SlideGenerator.Generating/Services/ValidationService.cs @@ -0,0 +1,49 @@ +using SlideGenerator.Generating.Models; + +namespace SlideGenerator.Generating.Services; + +public static class ValidationService +{ + /// + /// Resolves the list of sheet names to be used for slide generation based on the user's selection or the available + /// templates. + /// + /// If the user has not selected any sheets, all available template keys are returned to ensure + /// that slide generation includes all possible sheets. + /// The request containing the selected sheets and the template mapping used to determine which sheets to include. + /// A read-only list of strings representing the selected sheet names. If no sheets are selected, returns the keys + /// from the template map. + public static IReadOnlyList ResolveSelectedSheets(GenerateSlidesRequest request) + { + return request.Sheet.SelectedSheets is { Count: > 0 } + ? request.Sheet.SelectedSheets + : request.TemplateMap.Keys.ToList(); + } + + /// + /// Validates the specified slide generation request to ensure that all required templates and data files are + /// present and correctly configured.` + /// + /// Call this method before processing a slide generation request to verify that all necessary + /// files and configuration values are valid. This helps prevent runtime errors due to missing files or invalid + /// template indices. + /// The slide generation request containing the template mappings and sheet data to validate. Cannot be null. + /// Thrown if the request does not contain at least one template, or if any template's index is less than 1. + /// Thrown if the specified sheet data file or any template file does not exist at the provided file path. + public static void ValidateRequest(GenerateSlidesRequest request) + { + if (request.TemplateMap.Count == 0) + throw new InvalidOperationException("At least one template is required."); + + if (!File.Exists(request.Sheet.FilePath)) + throw new FileNotFoundException("Sheet data file not found.", request.Sheet.FilePath); + + foreach (var template in request.TemplateMap.Values) + { + if (!File.Exists(template.FilePath)) + throw new FileNotFoundException("Template file not found.", template.FilePath); + if (template.Index < 1) + throw new InvalidOperationException("TemplateSlideIndex must be >= 1."); + } + } +} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Infrastructure/SlideGenerator.Infrastructure.csproj b/backend/src/SlideGenerator.Generating/SlideGenerator.Generating.csproj similarity index 63% rename from backend/src/SlideGenerator.Infrastructure/SlideGenerator.Infrastructure.csproj rename to backend/src/SlideGenerator.Generating/SlideGenerator.Generating.csproj index 5dfb90a5..7d13b99e 100644 --- a/backend/src/SlideGenerator.Infrastructure/SlideGenerator.Infrastructure.csproj +++ b/backend/src/SlideGenerator.Generating/SlideGenerator.Generating.csproj @@ -5,22 +5,16 @@ enable enable true + GPL-3.0-only $(NoWarn);1591 win-x64 - GPL-3.0-only + false - - + - - - - - - @@ -36,4 +30,10 @@ + + + + + + diff --git a/backend/src/SlideGenerator.Infrastructure/Common/Base/Service.cs b/backend/src/SlideGenerator.Infrastructure/Common/Base/Service.cs deleted file mode 100644 index e7dd01b0..00000000 --- a/backend/src/SlideGenerator.Infrastructure/Common/Base/Service.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Microsoft.Extensions.Logging; - -namespace SlideGenerator.Infrastructure.Common.Base; - -/// -/// Base class for services. -/// -public abstract class Service(ILogger logger) -{ - protected ILogger Logger { get; } = logger; -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Infrastructure/Common/Logging/LoggingExtensions.cs b/backend/src/SlideGenerator.Infrastructure/Common/Logging/LoggingExtensions.cs deleted file mode 100644 index 718d111f..00000000 --- a/backend/src/SlideGenerator.Infrastructure/Common/Logging/LoggingExtensions.cs +++ /dev/null @@ -1,47 +0,0 @@ -using Microsoft.AspNetCore.Builder; -using Serilog; - -namespace SlideGenerator.Infrastructure.Common.Logging; - -/// -/// Provides extension methods for setting up logging within the infrastructure layer. -/// -public static class LoggingExtensions -{ - /// - /// Log output template matching frontend format: - /// [timestamp] [LEVEL] [Source] Message - /// - private const string LogTemplate = - "{Timestamp:yyyy-MM-dd HH:mm:ss.fff} [{Level:u3}] [{SourceContext}] {Message:lj}{NewLine}{Exception}"; - - /// - /// Configures Serilog for the application, reading configuration from appsettings and environment variables. - /// It sets up console logging and file logging if the SLIDEGEN_LOG_PATH environment variable is provided. - /// - /// The to configure. - public static void AddInfrastructureLogging(this WebApplicationBuilder builder) - { - var logPath = Environment.GetEnvironmentVariable("SLIDEGEN_LOG_PATH"); - - var loggerConfig = new LoggerConfiguration() - .ReadFrom.Configuration(builder.Configuration) - .Enrich.FromLogContext() - .WriteTo.Console(outputTemplate: LogTemplate); - - if (!string.IsNullOrWhiteSpace(logPath)) - loggerConfig.WriteTo.File(logPath, outputTemplate: LogTemplate); - - builder.Host.UseSerilog(loggerConfig.CreateLogger()); - } - - /// - /// Statically closes and flushes the global , ensuring all buffered logs are written. - /// This should be called on application shutdown. - /// - /// A task that completes when the logger is flushed. - public static async Task CloseAndFlushAsync() - { - await Log.CloseAndFlushAsync(); - } -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Infrastructure/Common/Utilities/PathUtils.cs b/backend/src/SlideGenerator.Infrastructure/Common/Utilities/PathUtils.cs deleted file mode 100644 index 8c5a7765..00000000 --- a/backend/src/SlideGenerator.Infrastructure/Common/Utilities/PathUtils.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System.Collections.Immutable; - -namespace SlideGenerator.Infrastructure.Common.Utilities; - -/// -/// Provides utility methods for working with file system paths and file names. -/// -internal static class PathUtils -{ - private static IImmutableSet InvalidPathChars { get; } = - ImmutableHashSet.Create(Path.GetInvalidPathChars()); - - /// - /// Removes invalid path characters from the specified file name and returns a sanitized version suitable for use as - /// a file name. - /// - /// - /// This method removes any characters from the input that are considered invalid for file paths, - /// as defined by the application's configuration. The returned file name is trimmed of leading and trailing - /// whitespace. - /// - /// The file name to sanitize. Cannot be null. - /// The character to replace invalid path characters with. Defaults to underscore ('_'). - /// - /// A sanitized file name with all invalid path characters removed. Returns "unnamed" if the resulting file name is - /// empty or consists only of whitespace. - /// - public static string SanitizeFileName(string fileName, char replacement = '_') - { - if (string.IsNullOrWhiteSpace(fileName)) - return "unnamed"; - - var buffer = new char[fileName.Length]; - var length = 0; - - foreach (var c in fileName) - buffer[length++] = InvalidPathChars.Contains(c) - ? replacement - : c; - - var result = new string(buffer, 0, length).Trim(); - return result.Length == 0 ? "unnamed" : result; - } -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Infrastructure/Common/Utilities/UrlUtils.cs b/backend/src/SlideGenerator.Infrastructure/Common/Utilities/UrlUtils.cs deleted file mode 100644 index b14f0ace..00000000 --- a/backend/src/SlideGenerator.Infrastructure/Common/Utilities/UrlUtils.cs +++ /dev/null @@ -1,62 +0,0 @@ -namespace SlideGenerator.Infrastructure.Common.Utilities; - -internal static class UrlUtils -{ - /// - /// Attempts to parse and normalize the specified URL as an absolute HTTP or HTTPS URI. - /// - /// - /// If the input does not specify a scheme, "https://" is assumed. Only absolute HTTP and HTTPS - /// URLs are considered valid. - /// - /// The raw URL string to normalize. May be null or empty. - /// - /// When this method returns, contains the normalized absolute URI if parsing succeeds and the scheme is HTTP or - /// HTTPS; otherwise, null. - /// - /// true if the URL was successfully parsed and normalized as an absolute HTTP or HTTPS URI; otherwise, false. - public static bool TryNormalizeHttpsUrl(string? rawUrl, out Uri? uri) - { - uri = null; - if (string.IsNullOrWhiteSpace(rawUrl)) - return false; - - rawUrl = rawUrl.Trim(); - if (!rawUrl.Contains("://", StringComparison.Ordinal)) - rawUrl = "https://" + rawUrl; - - if (!Uri.TryCreate(rawUrl, UriKind.Absolute, out var created)) - return false; - - if (created.Scheme != Uri.UriSchemeHttp && - created.Scheme != Uri.UriSchemeHttps) - return false; - - uri = created; - return true; - } - - public static bool IsImageFileUrl(string url, HttpClient? httpClient = null) - { - httpClient ??= new HttpClient(); - - try - { - using var request = new HttpRequestMessage(HttpMethod.Head, url); - using var response = httpClient.Send(request); - if (response is { IsSuccessStatusCode: true }) - { - var contentType = response.Content.Headers.ContentType?.MediaType; - return contentType != null - && contentType.StartsWith("image/", - StringComparison.OrdinalIgnoreCase); - } - } - catch - { - // Ignore exceptions and treat as non-image URL - } - - return false; - } -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Infrastructure/Features/Configs/ConfigLoader.cs b/backend/src/SlideGenerator.Infrastructure/Features/Configs/ConfigLoader.cs deleted file mode 100644 index 61e5a3a6..00000000 --- a/backend/src/SlideGenerator.Infrastructure/Features/Configs/ConfigLoader.cs +++ /dev/null @@ -1,56 +0,0 @@ -using SlideGenerator.Domain.Configs; -using YamlDotNet.Serialization; -using YamlDotNet.Serialization.NamingConventions; - -namespace SlideGenerator.Infrastructure.Features.Configs; - -public static class ConfigLoader -{ - /// - /// Loads/Reloads configuration. - /// - /// A lock object used to synchronize access during the operation. - public static Config? Load(Lock @lock) - { - lock (@lock) - { - if (File.Exists(Config.FileName)) - try - { - var yaml = File.ReadAllText(Config.FileName); - var deserializer = new DeserializerBuilder() - .WithNamingConvention(UnderscoredNamingConvention.Instance) - .IgnoreUnmatchedProperties() - .Build(); - - return deserializer.Deserialize(yaml); - } - catch - { - // TODO: Log Error - } - - return null; - } - } - - /// - /// Saves current configuration to the YAML file. - /// - /// The configuration object to save. - /// A lock object used to synchronize access during the operation. - public static void Save(Config config, Lock @lock) - { - lock (@lock) - { - var directory = Path.GetDirectoryName(Config.FileName); - if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory); - - var serializer = new SerializerBuilder() - .WithNamingConvention(UnderscoredNamingConvention.Instance) - .Build(); - var yaml = serializer.Serialize(config); - File.WriteAllText(Config.FileName, yaml); - } - } -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Infrastructure/Features/Downloads/Models/DownloadImageTask.cs b/backend/src/SlideGenerator.Infrastructure/Features/Downloads/Models/DownloadImageTask.cs deleted file mode 100644 index edded881..00000000 --- a/backend/src/SlideGenerator.Infrastructure/Features/Downloads/Models/DownloadImageTask.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System.Net.Http.Headers; -using Downloader; -using Microsoft.Extensions.Logging; -using SlideGenerator.Application.Features.Configs; -using SlideGenerator.Framework.Cloud; -using SlideGenerator.Infrastructure.Common.Utilities; -using SlideGenerator.Infrastructure.Features.Images.Exceptions; - -namespace SlideGenerator.Infrastructure.Features.Downloads.Models; - -/// -/// Represents a generic download task wrapping Downloader.DownloadService. -/// -public sealed class DownloadImageTask(string url, DirectoryInfo saveFolder, ILoggerFactory? loggerFactory = null) - : DownloadTask(url, saveFolder, new RequestConfiguration - { - Accept = "image/*", - Proxy = ConfigHolder.Value.Download.Proxy.GetWebProxy() - }, loggerFactory) -{ - public override async Task DownloadFileAsync() - { - var httpClient = new HttpClient(new HttpClientHandler - { - UseProxy = true, - Proxy = ConfigHolder.Value.Download.Proxy.GetWebProxy(), - AllowAutoRedirect = true - }); - - var resolvedUri = await CloudUrlResolver.ResolveLinkAsync(Url, httpClient); - Url = resolvedUri.ToString(); - - httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("image/*")); - if (!UrlUtils.IsImageFileUrl(Url, httpClient)) - throw new NotImageFileUrl(Url); - - await base.DownloadFileAsync(); - } -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Infrastructure/Features/Downloads/Models/DownloadTask.cs b/backend/src/SlideGenerator.Infrastructure/Features/Downloads/Models/DownloadTask.cs deleted file mode 100644 index 41711288..00000000 --- a/backend/src/SlideGenerator.Infrastructure/Features/Downloads/Models/DownloadTask.cs +++ /dev/null @@ -1,154 +0,0 @@ -using System.ComponentModel; -using Downloader; -using Microsoft.Extensions.Logging; -using SlideGenerator.Application.Features.Configs; -using SlideGenerator.Domain.Features.Downloads; -using SlideGenerator.Domain.Features.Downloads.Events; -using DownloadStatus = SlideGenerator.Domain.Features.Downloads.Enums.DownloadStatus; - -namespace SlideGenerator.Infrastructure.Features.Downloads.Models; - -public abstract class DownloadTask : IDownloadTask, IDisposable -{ - private readonly DownloadService _downloader; - private bool _disposed; - - protected DownloadTask(string url, DirectoryInfo saveFolder, - RequestConfiguration? requestConfiguration = null, ILoggerFactory? loggerFactory = null) - { - Url = url; - SaveFolder = saveFolder; - - var config = ConfigHolder.Value.Download; - _downloader = new DownloadService(new DownloadConfiguration - { - RequestConfiguration = - requestConfiguration - ?? new RequestConfiguration { Proxy = config.Proxy.GetWebProxy() }, - ChunkCount = config.MaxChunks, - ParallelDownload = true, - MaximumBytesPerSecond = config.LimitBytesPerSecond, - Timeout = config.Retry.Timeout * 1000, - MaxTryAgainOnFailure = config.Retry.MaxRetries, - ClearPackageOnCompletionWithFailure = true - }, loggerFactory); - - // Event hooks - _downloader.DownloadStarted += OnDownloadStarted; - _downloader.DownloadProgressChanged += OnDownloadProgressed; - _downloader.DownloadFileCompleted += OnDownloadCompleted; - } - - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - public string Url { get; protected set; } - public DirectoryInfo SaveFolder { get; init; } - public string FileName => _downloader.Package.FileName; - - public string FilePath - { - get - { - if (string.IsNullOrEmpty(FileName)) - return string.Empty; - if (string.IsNullOrEmpty(field)) - field = Path.Combine(SaveFolder.FullName, FileName); - return field; - } - } = string.Empty; - - public DownloadStatus Status => _downloader.Status switch - { - Downloader.DownloadStatus.None => DownloadStatus.None, - Downloader.DownloadStatus.Created => DownloadStatus.Created, - Downloader.DownloadStatus.Running => DownloadStatus.Running, - Downloader.DownloadStatus.Paused => DownloadStatus.Paused, - Downloader.DownloadStatus.Completed => DownloadStatus.Completed, - Downloader.DownloadStatus.Failed => DownloadStatus.Failed, - Downloader.DownloadStatus.Stopped => DownloadStatus.Cancelled, - _ => DownloadStatus.None - }; - - public long TotalSize => _downloader.Package?.TotalFileSize ?? 0; - public long DownloadedSize => _downloader.Package?.ReceivedBytesSize ?? 0; - public double Progress => TotalSize > 0 ? (double)DownloadedSize / TotalSize * 100 : 0; - public bool IsBusy => _downloader.IsBusy; - public bool IsPaused => _downloader.IsPaused; - public bool IsCancelled => _downloader.IsCancelled; - public event EventHandler? DownloadStartedEvents; - public event EventHandler? DownloadProgressedEvents; - public event EventHandler? DownloadCompletedEvents; - - public virtual async Task DownloadFileAsync() - { - if (Status == DownloadStatus.Cancelled) return; - - try - { - if (!SaveFolder.Exists) SaveFolder.Create(); - await _downloader.DownloadFileTaskAsync(Url, SaveFolder); - } - catch (IOException e) - { - DownloadCompletedEvents?.Invoke(this, - new DownloadCompletedArgs(false, FileName, FilePath, e)); - } - catch (Exception) - { - // handled by DownloadFileCompleted event - } - } - - public void Pause() - { - _downloader.Pause(); - } - - public void Resume() - { - _downloader.Resume(); - } - - public void Cancel() - { - _downloader.CancelAsync(); - } - - protected virtual void Dispose(bool disposing) - { - if (_disposed) return; - if (disposing) - { - _downloader.DownloadStarted -= OnDownloadStarted; - _downloader.DownloadProgressChanged -= OnDownloadProgressed; - _downloader.DownloadFileCompleted -= OnDownloadCompleted; - _downloader.Dispose(); - } - - _disposed = true; - } - - private void OnDownloadStarted(object? sender, DownloadStartedEventArgs args) - { - DownloadStartedEvents?.Invoke(sender, new DownloadStartedArgs( - Url, args.FileName, FilePath, args.TotalBytesToReceive)); - } - - private void OnDownloadProgressed(object? sender, DownloadProgressChangedEventArgs args) - { - DownloadProgressedEvents?.Invoke(sender, new DownloadProgressedArgs( - args.ReceivedBytesSize, - args.TotalBytesToReceive, - args.ProgressPercentage)); - } - - private void OnDownloadCompleted(object? sender, AsyncCompletedEventArgs args) - { - var success = args.Error == null && !args.Cancelled; - DownloadCompletedEvents?.Invoke(sender, new DownloadCompletedArgs(success, FileName, FilePath, args.Error)); - } -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Infrastructure/Features/Downloads/Services/DownloadService.cs b/backend/src/SlideGenerator.Infrastructure/Features/Downloads/Services/DownloadService.cs deleted file mode 100644 index 6740c384..00000000 --- a/backend/src/SlideGenerator.Infrastructure/Features/Downloads/Services/DownloadService.cs +++ /dev/null @@ -1,69 +0,0 @@ -using Microsoft.Extensions.Logging; -using SlideGenerator.Application.Features.Downloads; -using SlideGenerator.Domain.Features.Downloads; -using SlideGenerator.Infrastructure.Common.Base; -using SlideGenerator.Infrastructure.Features.Downloads.Models; - -namespace SlideGenerator.Infrastructure.Features.Downloads.Services; - -/// -/// Download service implementation using Downloader library. -/// -public class DownloadService(ILogger logger, ILoggerFactory? loggerFactory = null) - : Service(logger), IDownloadService, IDownloadClient -{ - public async Task DownloadAsync(Uri uri, DirectoryInfo saveFolder, - CancellationToken cancellationToken) - { - var task = CreateImageTask(uri.ToString(), saveFolder); - var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - - task.DownloadCompletedEvents += (_, args) => - { - tcs.TrySetResult(args.Success - ? new DownloadResult(true, args.FilePath, null) - : new DownloadResult(false, args.FilePath, args.Error?.Message)); - }; - - await using var registration = cancellationToken.Register(() => - { - task.Cancel(); - tcs.TrySetCanceled(cancellationToken); - }); - - await DownloadTask(task); - return await tcs.Task; - } - - public IDownloadTask CreateImageTask(string url, DirectoryInfo saveFolder) - { - var task = new DownloadImageTask(url, saveFolder, loggerFactory); - - // Hook logging events - task.DownloadStartedEvents += (_, args) => - { - Logger.LogInformation("Downloading: {FilePath} ({Url})", - args.FilePath, args.Url); - }; - task.DownloadProgressedEvents += (_, args) => - { - Logger.LogTrace("Progress: {FilePath} | {Downloaded}/{Total} ({Percent}%)", - task.FilePath, args.BytesReceived, args.TotalBytes, args.ProgressPercentage); - }; - task.DownloadCompletedEvents += (_, args) => - { - if (args.Success) - Logger.LogInformation("Completed: {FilePath}", args.FilePath); - else if (args.Error != null) - Logger.LogWarning("Failed: {FilePath} | {ExceptionType}: {ExceptionMsg}", - args.FilePath, args.Error?.GetType(), args.Error?.Message); - }; - - return task; - } - - public async Task DownloadTask(IDownloadTask task) - { - await task.DownloadFileAsync(); - } -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Infrastructure/Features/IO/FileSystem.cs b/backend/src/SlideGenerator.Infrastructure/Features/IO/FileSystem.cs deleted file mode 100644 index 663aaf5a..00000000 --- a/backend/src/SlideGenerator.Infrastructure/Features/IO/FileSystem.cs +++ /dev/null @@ -1,34 +0,0 @@ -using SlideGenerator.Domain.Features.IO; - -namespace SlideGenerator.Infrastructure.Features.IO; - -/// -/// File system implementation using System.IO. -/// -public sealed class FileSystem : IFileSystem -{ - /// - public bool FileExists(string path) - { - return File.Exists(path); - } - - /// - public void CopyFile(string sourcePath, string destinationPath, bool overwrite) - { - File.Copy(sourcePath, destinationPath, overwrite); - } - - /// - public void DeleteFile(string path) - { - if (File.Exists(path)) File.Delete(path); - } - - /// - public void EnsureDirectory(string path) - { - if (string.IsNullOrWhiteSpace(path)) return; - Directory.CreateDirectory(path); - } -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Infrastructure/Features/Images/Exceptions/NotImageFileUrl.cs b/backend/src/SlideGenerator.Infrastructure/Features/Images/Exceptions/NotImageFileUrl.cs deleted file mode 100644 index 2e93ed9d..00000000 --- a/backend/src/SlideGenerator.Infrastructure/Features/Images/Exceptions/NotImageFileUrl.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace SlideGenerator.Infrastructure.Features.Images.Exceptions; - -public class NotImageFileUrl(string url) - : ArgumentException($"URL {url} is not an valid image file.", nameof(url)) -{ - public string Url { get; } = url; -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Infrastructure/Features/Images/Services/ImageService.cs b/backend/src/SlideGenerator.Infrastructure/Features/Images/Services/ImageService.cs deleted file mode 100644 index 458c8514..00000000 --- a/backend/src/SlideGenerator.Infrastructure/Features/Images/Services/ImageService.cs +++ /dev/null @@ -1,111 +0,0 @@ -using System.Drawing; -using Microsoft.Extensions.Logging; -using SlideGenerator.Application.Features.Configs; -using SlideGenerator.Application.Features.Images; -using SlideGenerator.Domain.Features.Images.Enums; -using SlideGenerator.Framework.Image.Exceptions; -using SlideGenerator.Framework.Image.Modules.FaceDetection.Models; -using SlideGenerator.Framework.Image.Modules.Roi; -using SlideGenerator.Framework.Image.Modules.Roi.Configs; -using SlideGenerator.Framework.Image.Modules.Roi.Enums; -using SlideGenerator.Framework.Image.Modules.Roi.Models; -using SlideGenerator.Infrastructure.Common.Base; -using Image = SlideGenerator.Framework.Image.Models.Image; - -namespace SlideGenerator.Infrastructure.Features.Images.Services; - -/// -/// Image processing service implementation. -/// -public sealed class ImageService : Service, - IImageService, IDisposable -{ - private readonly FaceDetectorModel _faceDetectorMode; - private readonly Lazy _roiModule; - - public ImageService(ILogger logger) : base(logger) - { - var baseModel = new YuNetModel(); - _faceDetectorMode = new ResizingFaceDetectorModel(baseModel, - () => ConfigHolder.Value.Image.Face.MaxDimension, - logger); - _roiModule = new Lazy( - () => - { - var imageConfig = ConfigHolder.Value.Image; - var roiOptions = new RoiOptions - { - FaceConfidence = imageConfig.Face.Confidence, - FacesUnionAll = imageConfig.Face.UnionAll, - SaliencyPaddingRatio = new ExpandRatio( - imageConfig.Saliency.PaddingTop, - imageConfig.Saliency.PaddingBottom, - imageConfig.Saliency.PaddingLeft, - imageConfig.Saliency.PaddingRight - ) - }; - - return new RoiModule(roiOptions) - { - FaceDetectorModel = _faceDetectorMode - }; - }, - LazyThreadSafetyMode.ExecutionAndPublication); - } - - public void Dispose() - { - _faceDetectorMode.Dispose(); - } - - /// - public bool IsFaceModelAvailable => _faceDetectorMode.IsModelAvailable; - - /// - public Task InitFaceModelAsync() - { - return _faceDetectorMode.InitAsync(); - } - - /// - public Task DeInitFaceModelAsync() - { - return _faceDetectorMode.DeInitAsync(); - } - - public async Task CropImageAsync(string filePath, Size size, ImageRoiType roiType, ImageCropType cropType) - { - using var image = new Image(filePath); - try - { - var coreRoiType = roiType switch - { - ImageRoiType.RuleOfThirds => RoiType.RuleOfThirds, - ImageRoiType.Prominent => RoiType.Prominent, - ImageRoiType.Center => RoiType.Center, - _ => throw new ArgumentOutOfRangeException(nameof(roiType), roiType, null) - }; - var coreCropType = cropType switch - { - ImageCropType.Crop => CropType.Crop, - ImageCropType.Fit => CropType.Fit, - _ => throw new ArgumentOutOfRangeException(nameof(cropType), cropType, null) - }; - - var roiSelector = _roiModule.Value.GetRoiSelector(coreRoiType); - await RoiModule.CropToRoiAsync(image, size, roiSelector, coreCropType); - Logger.LogInformation( - "Cropped image {FilePath} to size {Width}x{Height} (Roi: {RoiMode}, Crop: {CropMode})", - filePath, image.Size.Width, image.Size.Height, roiType, cropType); - - return image.ToByteArray(); - } - catch (ReadImageFailed ex) - { - Logger.LogWarning(ex, - "Image processing unavailable for {FilePath}. Using PNG bytes without ROI.", - filePath); - return image.ToByteArray(); - } - } -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Infrastructure/Features/Images/Services/ResizingFaceDetectorModel.cs b/backend/src/SlideGenerator.Infrastructure/Features/Images/Services/ResizingFaceDetectorModel.cs deleted file mode 100644 index 975429e9..00000000 --- a/backend/src/SlideGenerator.Infrastructure/Features/Images/Services/ResizingFaceDetectorModel.cs +++ /dev/null @@ -1,141 +0,0 @@ -using System.Drawing; -using System.Reflection; -using System.Runtime.CompilerServices; -using Emgu.CV; -using Emgu.CV.CvEnum; -using Microsoft.Extensions.Logging; -using SlideGenerator.Framework.Image.Modules.FaceDetection.Models; -using CoreImage = SlideGenerator.Framework.Image.Models.Image; - -namespace SlideGenerator.Infrastructure.Features.Images.Services; - -/// -/// A wrapper for that resizes images before detection to improve performance. -/// -/// -/// Initializes a new instance of the class. -/// -/// The inner face detector model. -/// A function that returns the maximum allowed dimension (width or height). -/// The logger instance. -public sealed class ResizingFaceDetectorModel(FaceDetectorModel inner, Func maxDimensionProvider, ILogger logger) - : FaceDetectorModel -{ - private readonly FaceDetectorModel _inner = inner; - private readonly ILogger _logger = logger; - private readonly Func _maxDimensionProvider = maxDimensionProvider; - - public override bool IsModelAvailable => _inner.IsModelAvailable; - - public override void Dispose() - { - _inner.Dispose(); - } - - public override Task InitAsync() - { - return _inner.InitAsync(); - } - - public override Task DeInitAsync() - { - return _inner.DeInitAsync(); - } - - /// - /// Detects faces in the image, resizing it first if it exceeds the maximum dimension. - /// - /// The image to process. - /// The minimum confidence score. - /// A list of detected faces with coordinates scaled back to the original image size. - public override async Task> DetectAsync(CoreImage image, float minScore) - { - var maxDim = _maxDimensionProvider(); - - // If maxDim is 0 or negative, resizing is disabled. - var size = image.Size; - if (maxDim <= 0 || (size.Width <= maxDim && size.Height <= maxDim)) - return await _inner.DetectAsync(image, minScore); - - // Calculate new size - var scale = size.Width > size.Height - ? (double)maxDim / size.Width - : (double)maxDim / size.Height; - - var newWidth = (int)(size.Width * scale); - var newHeight = (int)(size.Height * scale); - var newSize = new Size(newWidth, newHeight); - - _logger.LogInformation( - "Resizing image for face detection from {Width}x{Height} to {NewWidth}x{NewHeight} (Scale: {Scale:F4})", - size.Width, size.Height, newWidth, newHeight, scale); - - CoreImage? resizedImage = null; - try - { - // Create resized Mat - var resizedMat = new Mat(); - CvInvoke.Resize(image.Mat, resizedMat, newSize, 0, 0, Inter.Area); - - // Create a dummy image instance without constructor - resizedImage = (CoreImage)RuntimeHelpers.GetUninitializedObject(typeof(CoreImage)); - - // Set properties via reflection - // Mat - var matProp = typeof(CoreImage).GetProperty("Mat", BindingFlags.Public | BindingFlags.Instance); - if (matProp != null) - { - matProp.SetValue(resizedImage, resizedMat); - } - else - { - // Fallback to field if property not found (unlikely as it is public) - resizedMat.Dispose(); - throw new InvalidOperationException("Could not find Mat property on Image class."); - } - - // SourceName - var sourceNameField = typeof(CoreImage).GetField("k__BackingField", - BindingFlags.NonPublic | BindingFlags.Instance); - sourceNameField?.SetValue(resizedImage, $"{image.SourceName} (Resized)"); - - var faces = await _inner.DetectAsync(resizedImage, minScore); - - // Scale faces back - var scaledFaces = new List(faces.Count); - foreach (var face in faces) scaledFaces.Add(ScaleFace(face, 1.0 / scale)); - return scaledFaces; - } - finally - { - resizedImage?.Dispose(); - } - } - - private static Face ScaleFace(Face face, double scale) - { - var rect = new Rectangle( - (int)Math.Round(face.Rect.X * scale), - (int)Math.Round(face.Rect.Y * scale), - (int)Math.Round(face.Rect.Width * scale), - (int)Math.Round(face.Rect.Height * scale) - ); - - Point? ScalePoint(Point? p) - { - return p.HasValue - ? new Point((int)Math.Round(p.Value.X * scale), (int)Math.Round(p.Value.Y * scale)) - : null; - } - - return new Face( - rect, - face.Score, - ScalePoint(face.RightEye), - ScalePoint(face.LeftEye), - ScalePoint(face.Nose), - ScalePoint(face.RightMouth), - ScalePoint(face.LeftMouth) - ); - } -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Infrastructure/Features/Jobs/Hangfire/SheetJobDisplayNameAttribute.cs b/backend/src/SlideGenerator.Infrastructure/Features/Jobs/Hangfire/SheetJobDisplayNameAttribute.cs deleted file mode 100644 index d76a3dad..00000000 --- a/backend/src/SlideGenerator.Infrastructure/Features/Jobs/Hangfire/SheetJobDisplayNameAttribute.cs +++ /dev/null @@ -1,34 +0,0 @@ -using Hangfire; -using Hangfire.Common; -using Hangfire.Dashboard; - -namespace SlideGenerator.Infrastructure.Features.Jobs.Hangfire; - -/// -/// Custom attribute to display sheet job names as "GroupName/SheetName" in Hangfire dashboard. -/// -public sealed class SheetJobDisplayNameAttribute : JobDisplayNameAttribute -{ - /// - /// Creates a new instance of the attribute. - /// - public SheetJobDisplayNameAttribute() : base("{0}") - { - } - - /// - public override string Format(DashboardContext context, Job job) - { - // Try to get the sheet ID from the job arguments - if (job.Args is not { Count: > 0 }) - return "Unknown Job"; - - var sheetId = job.Args[0]?.ToString(); - if (string.IsNullOrEmpty(sheetId)) - return "Unknown Job"; - - // Try to resolve the display name from the job name registry - var displayName = SheetJobNameRegistry.GetDisplayName(sheetId); - return displayName ?? $"Sheet: {sheetId[..Math.Min(8, sheetId.Length)]}..."; - } -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Infrastructure/Features/Jobs/Hangfire/SheetJobNameRegistry.cs b/backend/src/SlideGenerator.Infrastructure/Features/Jobs/Hangfire/SheetJobNameRegistry.cs deleted file mode 100644 index 21335956..00000000 --- a/backend/src/SlideGenerator.Infrastructure/Features/Jobs/Hangfire/SheetJobNameRegistry.cs +++ /dev/null @@ -1,51 +0,0 @@ -using System.Collections.Concurrent; - -namespace SlideGenerator.Infrastructure.Features.Jobs.Hangfire; - -/// -/// Thread-safe registry that stores display names for sheet jobs. -/// Format: "WorkbookName/SheetName" -/// -public static class SheetJobNameRegistry -{ - private static readonly ConcurrentDictionary DisplayNames = new(); - - /// - /// Registers a display name for a sheet job. - /// - /// The sheet job ID - /// The workbook/group name - /// The sheet name - public static void Register(string sheetId, string workbookName, string sheetName) - { - var displayName = $"{workbookName}/{sheetName}"; - DisplayNames[sheetId] = displayName; - } - - /// - /// Gets the display name for a sheet job. - /// - /// The sheet job ID - /// The display name, or null if not found - public static string? GetDisplayName(string sheetId) - { - return DisplayNames.GetValueOrDefault(sheetId); - } - - /// - /// Removes a sheet job from the registry. - /// - /// The sheet job ID - public static void Unregister(string sheetId) - { - DisplayNames.TryRemove(sheetId, out _); - } - - /// - /// Clears all registered display names. - /// - public static void Clear() - { - DisplayNames.Clear(); - } -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Infrastructure/Features/Jobs/Models/ActiveJobCollection.cs b/backend/src/SlideGenerator.Infrastructure/Features/Jobs/Models/ActiveJobCollection.cs deleted file mode 100644 index 88e30e3f..00000000 --- a/backend/src/SlideGenerator.Infrastructure/Features/Jobs/Models/ActiveJobCollection.cs +++ /dev/null @@ -1,651 +0,0 @@ -using System.Collections.Concurrent; -using Hangfire; -using Microsoft.Extensions.Logging; -using SlideGenerator.Application.Common.Utilities; -using SlideGenerator.Application.Features.Configs; -using SlideGenerator.Application.Features.Jobs.Contracts; -using SlideGenerator.Application.Features.Jobs.Contracts.Collections; -using SlideGenerator.Application.Features.Jobs.DTOs.Requests; -using SlideGenerator.Application.Features.Sheets; -using SlideGenerator.Application.Features.Slides; -using SlideGenerator.Application.Features.Slides.DTOs.Components; -using SlideGenerator.Domain.Features.Images.Enums; -using SlideGenerator.Domain.Features.IO; -using SlideGenerator.Domain.Features.Jobs.Components; -using SlideGenerator.Domain.Features.Jobs.Entities; -using SlideGenerator.Domain.Features.Jobs.Enums; -using SlideGenerator.Domain.Features.Jobs.Interfaces; -using SlideGenerator.Domain.Features.Jobs.States; -using SlideGenerator.Infrastructure.Common.Utilities; -using SlideGenerator.Infrastructure.Features.Jobs.Hangfire; - -namespace SlideGenerator.Infrastructure.Features.Jobs.Models; - -/// -/// Manages active jobs (pending/running/paused). -/// -public class ActiveJobCollection( - ILogger logger, - ISheetService sheetService, - ISlideTemplateManager slideTemplateManager, - IBackgroundJobClient backgroundJobClient, - IJobStateStore jobStateStore, - IFileSystem fileSystem, - IJobNotifier jobNotifier, - Action onGroupCompleted) : IActiveJobCollection -{ - private readonly ConcurrentDictionary _groupIdByOutputPath = new(StringComparer.OrdinalIgnoreCase); - private readonly ConcurrentDictionary _groups = new(); - private readonly ConcurrentDictionary _sheets = new(); - - #region IJobCollection Implementation - - /// - public IJobGroup? GetGroup(string groupId) - { - return _groups.GetValueOrDefault(groupId); - } - - /// - public IReadOnlyDictionary GetAllGroups() - { - var result = new Dictionary(_groups.Count); - foreach (var kv in _groups) - result.Add(kv.Key, kv.Value); - return result; - } - - /// - public IEnumerable EnumerateGroups() - { - return _groups.Values; - } - - /// - public int GroupCount => _groups.Count; - - /// - public IJobSheet? GetSheet(string sheetId) - { - return _sheets.GetValueOrDefault(sheetId); - } - - /// - public IReadOnlyDictionary GetAllSheets() - { - var result = new Dictionary(_sheets.Count); - foreach (var kv in _sheets) - result.Add(kv.Key, kv.Value); - return result; - } - - /// - public IEnumerable EnumerateSheets() - { - return _sheets.Values; - } - - /// - public int SheetCount => _sheets.Count; - - /// - public bool ContainsGroup(string groupId) - { - return _groups.ContainsKey(groupId); - } - - /// - public bool ContainsSheet(string sheetId) - { - return _sheets.ContainsKey(sheetId); - } - - /// - public bool IsEmpty => _groups.IsEmpty; - - #endregion - - #region Group Lifecycle - - /// - public IJobGroup CreateGroup(JobCreate request) - { - var workbook = sheetService.OpenFile(request.SpreadsheetPath); - var sheetsInfo = sheetService.GetSheetsInfo(workbook); - - var templatePath = request.TemplatePath; - slideTemplateManager.AddTemplate(templatePath); - var template = slideTemplateManager.GetTemplate(templatePath); - - List sheetNames; - if (request.JobType == JobType.Sheet) - { - if (string.IsNullOrWhiteSpace(request.SheetName)) - throw new InvalidOperationException("SheetName is required for sheet jobs."); - - var resolvedSheet = sheetsInfo.Keys.FirstOrDefault(name => - string.Equals(name, request.SheetName, StringComparison.OrdinalIgnoreCase)); - if (string.IsNullOrWhiteSpace(resolvedSheet)) - throw new InvalidOperationException($"Sheet '{request.SheetName}' not found in workbook."); - - sheetNames = [resolvedSheet]; - } - else - { - var requestedSheets = request.SheetNames; - if (requestedSheets?.Length > 0) - { - var requestedSet = new HashSet(requestedSheets, StringComparer.OrdinalIgnoreCase); - sheetNames = sheetsInfo.Keys.Where(name => requestedSet.Contains(name)).ToList(); - if (sheetNames.Count == 0) - throw new InvalidOperationException("No requested sheets found in workbook."); - } - else - { - sheetNames = sheetsInfo.Keys.ToList(); - } - } - - var outputRoot = request.OutputPath; - if (string.IsNullOrWhiteSpace(outputRoot)) - throw new InvalidOperationException("Output path is required."); - - var fullOutputPath = Path.GetFullPath(outputRoot); - var outputFolderPath = OutputPathUtils.NormalizeOutputFolderPath(fullOutputPath); - var outputFolder = new DirectoryInfo(outputFolderPath); - fileSystem.EnsureDirectory(outputFolder.FullName); - - var textConfigs = MapTextConfigs(request.TextConfigs); - var imageConfigs = MapImageConfigs(request.ImageConfigs); - - var group = new JobGroup( - workbook, - template, - outputFolder, - textConfigs, - imageConfigs); - - var outputOverrides = new Dictionary(StringComparer.OrdinalIgnoreCase); - if (HasPptxExtension(fullOutputPath) && sheetNames.Count == 1) - outputOverrides[sheetNames[0]] = fullOutputPath; - - foreach (var sheetName in sheetNames) - { - var sanitizedSheetName = PathUtils.SanitizeFileName(sheetName); - var outputPath = outputOverrides.TryGetValue(sheetName, out var overriddenPath) - ? overriddenPath - : Path.Combine(outputFolder.FullName, $"{sanitizedSheetName}.pptx"); - var job = group.AddJob(sheetName, outputPath); - _sheets[job.Id] = job; - - // Register display name for Hangfire dashboard - RegisterJobDisplayName(group, job); - } - - _groups[group.Id] = group; - _groupIdByOutputPath[outputFolder.FullName] = group.Id; - - PersistGroupState(group); - foreach (var sheet in group.InternalJobs.Values) - PersistSheetState(sheet); - - logger.LogInformation("Created group {GroupId} with {JobCount} jobs", group.Id, group.Sheets.Count); - - return group; - } - - /// - public void StartGroup(string groupId) - { - if (!_groups.TryGetValue(groupId, out var group)) - { - logger.LogWarning("Group {GroupId} not found", groupId); - return; - } - - group.SetStatus(GroupStatus.Running); - PersistGroupState(group); - jobNotifier.NotifyGroupStatusChanged(group.Id, group.Status).GetAwaiter().GetResult(); - - foreach (var job in group.InternalJobs.Values.Where(j => j.Status == SheetJobStatus.Pending)) - { - var hangfireJobId = backgroundJobClient.Enqueue(executor => - executor.ExecuteJobAsync(job.Id, CancellationToken.None)); - job.HangfireJobId = hangfireJobId; - PersistSheetState(job); - } - - logger.LogInformation("Started group {GroupId}", groupId); - } - - /// - public void PauseGroup(string groupId) - { - if (!_groups.TryGetValue(groupId, out var group)) return; - - foreach (var job in group.InternalJobs.Values.Where(j => - j.Status is SheetJobStatus.Pending or SheetJobStatus.Running)) - PauseSheetInternal(job); - - group.SetStatus(GroupStatus.Paused); - PersistGroupState(group); - jobNotifier.NotifyGroupStatusChanged(group.Id, group.Status).GetAwaiter().GetResult(); - logger.LogInformation("Paused group {GroupId}", groupId); - } - - /// - public void ResumeGroup(string groupId) - { - if (!_groups.TryGetValue(groupId, out var group)) return; - - var pausedJobs = group.InternalJobs.Values - .Where(j => j.Status == SheetJobStatus.Paused) - .ToList(); - var availableSlots = GetAvailableResumeSlots(); - var resumedCount = 0; - var pendingCount = 0; - - foreach (var job in pausedJobs) - { - if (job.IsExecuting) - { - ResumeSheetInternal(job); - resumedCount++; - continue; - } - - if (availableSlots > 0) - { - ResumeSheetInternal(job); - availableSlots--; - resumedCount++; - continue; - } - - job.Resume(); - QueueJobIfNeeded(job); - job.SetStatus(SheetJobStatus.Pending); - PersistSheetState(job); - jobNotifier.NotifyJobStatusChanged(job.Id, job.Status).GetAwaiter().GetResult(); - pendingCount++; - } - - UpdateGroupStatus(group.Id); - logger.LogInformation( - "Resumed group {GroupId} with {ResumedCount} jobs, {PendingCount} pending", - groupId, - resumedCount, - pendingCount); - } - - /// - public void CancelGroup(string groupId) - { - if (!_groups.TryGetValue(groupId, out var group)) return; - - foreach (var job in group.InternalJobs.Values.Where(j => - j.Status is SheetJobStatus.Pending or SheetJobStatus.Running or SheetJobStatus.Paused)) - CancelSheetInternal(job); - - group.SetStatus(GroupStatus.Cancelled); - PersistGroupState(group); - jobNotifier.NotifyGroupStatusChanged(group.Id, group.Status).GetAwaiter().GetResult(); - logger.LogInformation("Cancelled group {GroupId}", groupId); - - MoveToCompletedIfDone(group); - } - - /// - public void CancelAndRemoveGroup(string groupId) - { - if (!_groups.TryRemove(groupId, out var group)) return; - - foreach (var job in group.InternalJobs.Values) - { - if (job.Status is SheetJobStatus.Pending or SheetJobStatus.Running or SheetJobStatus.Paused) - CancelSheetInternal(job); - - _sheets.TryRemove(job.Id, out _); - SheetJobNameRegistry.Unregister(job.Id); - } - - group.SetStatus(GroupStatus.Cancelled); - jobNotifier.NotifyGroupStatusChanged(group.Id, group.Status).GetAwaiter().GetResult(); - - _groupIdByOutputPath.TryRemove(group.OutputFolder.FullName, out _); - group.Workbook.Dispose(); - jobStateStore.RemoveGroupAsync(group.Id, CancellationToken.None).GetAwaiter().GetResult(); - logger.LogInformation("Cancelled and removed group {GroupId}", group.Id); - } - - #endregion - - #region Sheet Lifecycle - - /// - public void PauseSheet(string sheetId) - { - if (_sheets.TryGetValue(sheetId, out var job)) - PauseSheetInternal(job); - } - - /// - public void ResumeSheet(string sheetId) - { - if (_sheets.TryGetValue(sheetId, out var job)) - ResumeSheetInternal(job); - } - - /// - public void CancelSheet(string sheetId) - { - if (_sheets.TryGetValue(sheetId, out var job)) - { - CancelSheetInternal(job); - CheckAndMoveGroupIfDone(job.GroupId); - } - } - - /// - public void CancelAndRemoveSheet(string sheetId) - { - if (!_sheets.TryRemove(sheetId, out var job)) return; - SheetJobNameRegistry.Unregister(job.Id); - - if (job.Status is SheetJobStatus.Pending or SheetJobStatus.Running or SheetJobStatus.Paused) - CancelSheetInternal(job); - - jobStateStore.RemoveSheetAsync(job.Id, CancellationToken.None).GetAwaiter().GetResult(); - - if (_groups.TryGetValue(job.GroupId, out var group)) - { - group.RemoveJob(job.Id); - if (group.InternalJobs.Count == 0) - { - _groups.TryRemove(group.Id, out _); - _groupIdByOutputPath.TryRemove(group.OutputFolder.FullName, out _); - group.Workbook.Dispose(); - jobStateStore.RemoveGroupAsync(group.Id, CancellationToken.None).GetAwaiter().GetResult(); - logger.LogInformation("Removed group {GroupId} after deleting last sheet", group.Id); - return; - } - - group.UpdateStatus(); - PersistGroupState(group); - jobNotifier.NotifyGroupStatusChanged(group.Id, group.Status).GetAwaiter().GetResult(); - } - - logger.LogInformation("Cancelled and removed sheet {SheetId}", job.Id); - } - - #endregion - - #region Bulk Operations - - /// - public void PauseAll() - { - foreach (var group in _groups.Values.Where(g => g.Status == GroupStatus.Running)) - PauseGroup(group.Id); - } - - /// - public void ResumeAll() - { - foreach (var group in _groups.Values.Where(g => g.Status == GroupStatus.Paused)) - ResumeGroup(group.Id); - } - - /// - public void CancelAll() - { - foreach (var group in _groups.Values.Where(g => - g.Status is GroupStatus.Pending or GroupStatus.Running or GroupStatus.Paused)) - CancelGroup(group.Id); - } - - #endregion - - #region Query - - /// - public bool HasActiveJobs => !_groups.IsEmpty; - - /// - public IReadOnlyDictionary GetRunningGroups() - { - var result = new Dictionary(); - foreach (var kv in _groups) - if (kv.Value.Status == GroupStatus.Running) - result.Add(kv.Key, kv.Value); - return result; - } - - /// - public IReadOnlyDictionary GetPausedGroups() - { - var result = new Dictionary(); - foreach (var kv in _groups) - if (kv.Value.Status == GroupStatus.Paused) - result.Add(kv.Key, kv.Value); - return result; - } - - /// - public IReadOnlyDictionary GetPendingGroups() - { - var result = new Dictionary(); - foreach (var kv in _groups) - if (kv.Value.Status == GroupStatus.Pending) - result.Add(kv.Key, kv.Value); - return result; - } - - /// - public IJobGroup? GetGroupByOutputPath(string outputFolderPath) - { - var normalizedPath = OutputPathUtils.NormalizeOutputFolderPath(outputFolderPath); - if (_groupIdByOutputPath.TryGetValue(normalizedPath, out var groupId)) - return _groups.GetValueOrDefault(groupId); - return null; - } - - #endregion - - #region Internal Methods - - internal JobSheet? GetInternalSheet(string sheetId) - { - return _sheets.GetValueOrDefault(sheetId); - } - - internal JobGroup? GetInternalGroup(string groupId) - { - return _groups.GetValueOrDefault(groupId); - } - - internal JobGroup? GetInternalGroupByOutputPath(string outputFolderPath) - { - var normalizedPath = OutputPathUtils.NormalizeOutputFolderPath(outputFolderPath); - if (_groupIdByOutputPath.TryGetValue(normalizedPath, out var groupId)) - return _groups.GetValueOrDefault(groupId); - return null; - } - - internal void NotifySheetCompleted(string sheetId) - { - if (_sheets.TryGetValue(sheetId, out var job)) - CheckAndMoveGroupIfDone(job.GroupId); - } - - internal void RestoreGroup(JobGroup group) - { - _groups[group.Id] = group; - _groupIdByOutputPath[group.OutputFolder.FullName] = group.Id; - foreach (var sheet in group.InternalJobs.Values) - { - _sheets[sheet.Id] = sheet; - - // Register display name for Hangfire dashboard - RegisterJobDisplayName(group, sheet); - } - } - - private void PauseSheetInternal(JobSheet job) - { - job.Pause(); - PersistSheetState(job); - jobNotifier.NotifyJobStatusChanged(job.Id, job.Status).GetAwaiter().GetResult(); - UpdateGroupStatus(job.GroupId); - logger.LogInformation("Paused job {JobId}{HangfireSuffix}", job.Id, - FormatHangfireSuffix(job.HangfireJobId)); - } - - private void ResumeSheetInternal(JobSheet job) - { - if (job.Status != SheetJobStatus.Paused) return; - - job.Resume(); - job.SetStatus(SheetJobStatus.Running); - - QueueJobIfNeeded(job); - - PersistSheetState(job); - jobNotifier.NotifyJobStatusChanged(job.Id, job.Status).GetAwaiter().GetResult(); - UpdateGroupStatus(job.GroupId); - logger.LogInformation("Resumed job {JobId}{HangfireSuffix}", job.Id, - FormatHangfireSuffix(job.HangfireJobId)); - } - - private void CancelSheetInternal(JobSheet job) - { - job.CancellationTokenSource.Cancel(); - if (job.HangfireJobId != null) - backgroundJobClient.Delete(job.HangfireJobId); - job.SetStatus(SheetJobStatus.Cancelled); - PersistSheetState(job); - jobNotifier.NotifyJobStatusChanged(job.Id, job.Status).GetAwaiter().GetResult(); - logger.LogInformation("Cancelled job {JobId}{HangfireSuffix}", job.Id, - FormatHangfireSuffix(job.HangfireJobId)); - } - - private void CheckAndMoveGroupIfDone(string groupId) - { - if (_groups.TryGetValue(groupId, out var group)) - { - group.UpdateStatus(); - PersistGroupState(group); - jobNotifier.NotifyGroupStatusChanged(group.Id, group.Status).GetAwaiter().GetResult(); - MoveToCompletedIfDone(group); - } - } - - private void UpdateGroupStatus(string groupId) - { - if (!_groups.TryGetValue(groupId, out var group)) return; - group.UpdateStatus(); - PersistGroupState(group); - jobNotifier.NotifyGroupStatusChanged(group.Id, group.Status).GetAwaiter().GetResult(); - } - - private void MoveToCompletedIfDone(JobGroup group) - { - if (!group.IsActive) - if (_groups.TryRemove(group.Id, out _)) - { - foreach (var sheet in group.InternalJobs.Values) - { - _sheets.TryRemove(sheet.Id, out _); - SheetJobNameRegistry.Unregister(sheet.Id); - } - - group.Workbook.Dispose(); - onGroupCompleted(group); - logger.LogInformation("Moved group {GroupId} to completed collection", group.Id); - } - } - - private int GetAvailableResumeSlots() - { - var maxConcurrentJobs = ConfigHolder.Value.Job.MaxConcurrentJobs; - var executingJobs = _sheets.Values.Count(job => job.IsExecuting); - return Math.Max(0, maxConcurrentJobs - executingJobs); - } - - private void QueueJobIfNeeded(JobSheet job) - { - if (job.IsExecuting || job.HangfireJobId != null) return; - var hangfireJobId = - backgroundJobClient.Enqueue(executor => - executor.ExecuteJobAsync(job.Id, CancellationToken.None)); - job.HangfireJobId = hangfireJobId; - } - - private void PersistGroupState(JobGroup group) - { - var state = new GroupJobState( - group.Id, - group.Workbook.FilePath, - group.Template.FilePath, - group.OutputFolder.FullName, - group.Status, - group.CreatedAt, - group.InternalJobs.Keys.ToList(), - group.ErrorCount); - - jobStateStore.SaveGroupAsync(state, CancellationToken.None).GetAwaiter().GetResult(); - } - - private void PersistSheetState(JobSheet sheet) - { - var state = new SheetJobState( - sheet.Id, - sheet.GroupId, - sheet.SheetName, - sheet.OutputPath, - sheet.Status, - sheet.NextRowIndex, - sheet.TotalRows, - sheet.ErrorCount, - sheet.ErrorMessage, - sheet.TextConfigs, - sheet.ImageConfigs); - - jobStateStore.SaveSheetAsync(state, CancellationToken.None).GetAwaiter().GetResult(); - } - - private static JobTextConfig[] MapTextConfigs(SlideTextConfig[]? configs) - { - if (configs == null || configs.Length == 0) return []; - return configs.Select(c => new JobTextConfig(c.Pattern, c.Columns)).ToArray(); - } - - private static JobImageConfig[] MapImageConfigs(SlideImageConfig[]? configs) - { - if (configs == null || configs.Length == 0) return []; - - return configs.Select(c => new JobImageConfig( - c.ShapeId, - c.RoiType ?? ImageRoiType.Center, - c.CropType ?? ImageCropType.Crop, - c.Columns)).ToArray(); - } - - private static bool HasPptxExtension(string path) - { - return string.Equals(Path.GetExtension(path), ".pptx", StringComparison.OrdinalIgnoreCase); - } - - private static string FormatHangfireSuffix(string? hangfireJobId) - { - return string.IsNullOrWhiteSpace(hangfireJobId) ? string.Empty : $" (#{hangfireJobId})"; - } - - private static void RegisterJobDisplayName(JobGroup group, JobSheet sheet) - { - var workbookName = Path.GetFileName(group.Workbook.FilePath); - SheetJobNameRegistry.Register(sheet.Id, workbookName, sheet.SheetName); - } - - #endregion -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Infrastructure/Features/Jobs/Models/CompletedJobCollection.cs b/backend/src/SlideGenerator.Infrastructure/Features/Jobs/Models/CompletedJobCollection.cs deleted file mode 100644 index c6fbf213..00000000 --- a/backend/src/SlideGenerator.Infrastructure/Features/Jobs/Models/CompletedJobCollection.cs +++ /dev/null @@ -1,212 +0,0 @@ -using System.Collections.Concurrent; -using Microsoft.Extensions.Logging; -using SlideGenerator.Application.Features.Jobs.Contracts.Collections; -using SlideGenerator.Domain.Features.Jobs.Entities; -using SlideGenerator.Domain.Features.Jobs.Enums; -using SlideGenerator.Domain.Features.Jobs.Interfaces; -using SlideGenerator.Domain.Features.Jobs.States; - -namespace SlideGenerator.Infrastructure.Features.Jobs.Models; - -/// -/// -/// Manages completed jobs (finished, failed, cancelled) -/// -public class CompletedJobCollection( - ILogger logger, - IJobStateStore jobStateStore) - : ICompletedJobCollection -{ - private readonly ConcurrentDictionary _groups = new(); - private readonly ConcurrentDictionary _sheets = new(); - - #region Internal Methods - - internal void AddGroup(JobGroup group) - { - _groups[group.Id] = group; - foreach (var sheet in group.InternalJobs.Values) - _sheets[sheet.Id] = sheet; - - logger.LogInformation("Added group {GroupId} to completed collection with status {Status}", - group.Id, group.Status); - } - - #endregion - - #region IJobCollection Implementation - - /// - public IJobGroup? GetGroup(string groupId) - { - return _groups.GetValueOrDefault(groupId); - } - - /// - public IReadOnlyDictionary GetAllGroups() - { - var result = new Dictionary(_groups.Count); - foreach (var kv in _groups) - result.Add(kv.Key, kv.Value); - return result; - } - - /// - public IEnumerable EnumerateGroups() - { - return _groups.Values; - } - - /// - public int GroupCount => _groups.Count; - - /// - public IJobSheet? GetSheet(string sheetId) - { - return _sheets.GetValueOrDefault(sheetId); - } - - /// - public IReadOnlyDictionary GetAllSheets() - { - var result = new Dictionary(_sheets.Count); - foreach (var kv in _sheets) - result.Add(kv.Key, kv.Value); - return result; - } - - /// - public IEnumerable EnumerateSheets() - { - return _sheets.Values; - } - - /// - public int SheetCount => _sheets.Count; - - /// - public bool ContainsGroup(string groupId) - { - return _groups.ContainsKey(groupId); - } - - /// - public bool ContainsSheet(string sheetId) - { - return _sheets.ContainsKey(sheetId); - } - - /// - public bool IsEmpty => _groups.IsEmpty; - - #endregion - - #region Remove Operations - - /// - public bool RemoveGroup(string groupId) - { - if (_groups.TryRemove(groupId, out var group)) - { - foreach (var sheet in group.InternalJobs.Values) - _sheets.TryRemove(sheet.Id, out _); - - jobStateStore.RemoveGroupAsync(groupId, CancellationToken.None).GetAwaiter().GetResult(); - logger.LogInformation("Removed completed group {GroupId}", groupId); - return true; - } - - return false; - } - - /// - public bool RemoveSheet(string sheetId) - { - if (_sheets.TryRemove(sheetId, out var sheet)) - { - if (_groups.TryGetValue(sheet.GroupId, out var group)) - { - group.RemoveJob(sheetId); - if (group.InternalJobs.Count == 0) - { - _groups.TryRemove(group.Id, out _); - jobStateStore.RemoveGroupAsync(group.Id, CancellationToken.None).GetAwaiter().GetResult(); - logger.LogInformation("Removed completed group {GroupId} after clearing last sheet", group.Id); - } - else - { - group.UpdateStatus(); - PersistGroupState(group); - } - } - - jobStateStore.RemoveSheetAsync(sheetId, CancellationToken.None).GetAwaiter().GetResult(); - logger.LogInformation("Removed completed sheet {SheetId}", sheetId); - return true; - } - - return false; - } - - /// - public void ClearAll() - { - var count = _groups.Count; - foreach (var groupId in _groups.Keys) - jobStateStore.RemoveGroupAsync(groupId, CancellationToken.None).GetAwaiter().GetResult(); - _groups.Clear(); - _sheets.Clear(); - logger.LogInformation("Cleared all {Count} completed groups", count); - } - - private void PersistGroupState(JobGroup group) - { - var state = new GroupJobState( - group.Id, - group.Workbook.FilePath, - group.Template.FilePath, - group.OutputFolder.FullName, - group.Status, - group.CreatedAt, - group.InternalJobs.Keys.ToList(), - group.ErrorCount); - - jobStateStore.SaveGroupAsync(state, CancellationToken.None).GetAwaiter().GetResult(); - } - - #endregion - - #region Query by Status - - /// - public IReadOnlyDictionary GetSuccessfulGroups() - { - var result = new Dictionary(); - foreach (var kv in _groups) - if (kv.Value.Status == GroupStatus.Completed) - result.Add(kv.Key, kv.Value); - return result; - } - - /// - public IReadOnlyDictionary GetFailedGroups() - { - var result = new Dictionary(); - foreach (var kv in _groups) - if (kv.Value.Status == GroupStatus.Failed) - result.Add(kv.Key, kv.Value); - return result; - } - - /// - public IReadOnlyDictionary GetCancelledGroups() - { - var result = new Dictionary(); - foreach (var kv in _groups) - if (kv.Value.Status == GroupStatus.Cancelled) - result.Add(kv.Key, kv.Value); - return result; - } - - #endregion -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Infrastructure/Features/Jobs/Services/HangfireJobStateStore.cs b/backend/src/SlideGenerator.Infrastructure/Features/Jobs/Services/HangfireJobStateStore.cs deleted file mode 100644 index 00bdac45..00000000 --- a/backend/src/SlideGenerator.Infrastructure/Features/Jobs/Services/HangfireJobStateStore.cs +++ /dev/null @@ -1,320 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Serialization; -using Hangfire; -using Hangfire.Storage; -using SlideGenerator.Domain.Features.Jobs.Enums; -using SlideGenerator.Domain.Features.Jobs.Interfaces; -using SlideGenerator.Domain.Features.Jobs.States; - -namespace SlideGenerator.Infrastructure.Features.Jobs.Services; - -/// -/// Persists job state using Hangfire storage (SQLite). -/// -public sealed class HangfireJobStateStore(JobStorage storage) : IJobStateStore -{ - private const string GroupKeyPrefix = "slidegen:group:"; - private const string SheetKeyPrefix = "slidegen:sheet:"; - private const string ActiveGroupsSet = "slidegen:groups:active"; - private const string AllGroupsSet = "slidegen:groups:all"; - private const string JobLogKeyPrefix = "slidegen:joblog:"; - private const int MaxLogEntries = 2000; - - private static readonly JsonSerializerOptions SerializerOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - Converters = { new JsonStringEnumConverter() } - }; - - /// - public Task SaveGroupAsync(GroupJobState state, CancellationToken cancellationToken) - { - var key = GroupKeyPrefix + state.Id; - var json = JsonSerializer.Serialize(state, SerializerOptions); - - using var connection = storage.GetConnection(); - using var tx = connection.CreateWriteTransaction(); - tx.SetRangeInHash(key, [new KeyValuePair("data", json)]); - tx.AddToSet(AllGroupsSet, state.Id); - - if (IsActive(state.Status)) - tx.AddToSet(ActiveGroupsSet, state.Id); - else - tx.RemoveFromSet(ActiveGroupsSet, state.Id); - - tx.Commit(); - return Task.CompletedTask; - } - - /// - public Task SaveSheetAsync(SheetJobState state, CancellationToken cancellationToken) - { - var key = SheetKeyPrefix + state.Id; - var json = JsonSerializer.Serialize(state, SerializerOptions); - - using var connection = storage.GetConnection(); - using var tx = connection.CreateWriteTransaction(); - tx.SetRangeInHash(key, [new KeyValuePair("data", json)]); - tx.AddToSet(GroupSheetsSet(state.GroupId), state.Id); - tx.Commit(); - - return Task.CompletedTask; - } - - /// - public Task GetGroupAsync(string groupId, CancellationToken cancellationToken) - { - using var connection = storage.GetConnection(); - var entries = connection.GetAllEntriesFromHash(GroupKeyPrefix + groupId); - if (entries == null || !entries.TryGetValue("data", out var json)) - return Task.FromResult(null); - - var state = JsonSerializer.Deserialize(json, SerializerOptions); - return Task.FromResult(state); - } - - /// - public Task GetSheetAsync(string sheetId, CancellationToken cancellationToken) - { - using var connection = storage.GetConnection(); - var entries = connection.GetAllEntriesFromHash(SheetKeyPrefix + sheetId); - if (entries == null || !entries.TryGetValue("data", out var json)) - return Task.FromResult(null); - - var state = JsonSerializer.Deserialize(json, SerializerOptions); - return Task.FromResult(state); - } - - /// - public async Task> GetActiveGroupsAsync(CancellationToken cancellationToken) - { - using var connection = storage.GetConnection(); - var ids = connection.GetAllItemsFromSet(ActiveGroupsSet); - var result = new List(); - foreach (var id in ids) - { - var state = await GetGroupAsync(id, cancellationToken); - if (state != null) - result.Add(state); - } - - return result; - } - - /// - public async Task> GetAllGroupsAsync(CancellationToken cancellationToken) - { - using var connection = storage.GetConnection(); - var ids = connection.GetAllItemsFromSet(AllGroupsSet); - var result = new List(); - foreach (var id in ids) - { - var state = await GetGroupAsync(id, cancellationToken); - if (state != null) - result.Add(state); - } - - return result; - } - - /// - public Task AppendJobLogAsync(JobLogEntry entry, CancellationToken cancellationToken) - { - return AppendJobLogsAsync([entry], cancellationToken); - } - - /// - public Task AppendJobLogsAsync(IReadOnlyCollection entries, CancellationToken cancellationToken) - { - if (entries.Count == 0) - return Task.CompletedTask; - - using var connection = storage.GetConnection(); - if (TryAppendListLogs(connection, entries)) - return Task.CompletedTask; - - foreach (var group in entries.GroupBy(entry => entry.JobId)) - { - var key = JobLogKeyPrefix + group.Key; - var logs = GetLegacyJobLogs(connection, key); - logs.AddRange(group); - - if (logs.Count > MaxLogEntries) - logs.RemoveRange(0, logs.Count - MaxLogEntries); - - var json = JsonSerializer.Serialize(logs, SerializerOptions); - using var tx = connection.CreateWriteTransaction(); - tx.SetRangeInHash(key, [new KeyValuePair("data", json)]); - tx.Commit(); - } - - return Task.CompletedTask; - } - - /// - public Task> GetJobLogsAsync(string jobId, CancellationToken cancellationToken) - { - var logs = GetJobLogsInternal(jobId); - return Task.FromResult>(logs); - } - - /// - public async Task> GetSheetsByGroupAsync(string groupId, - CancellationToken cancellationToken) - { - using var connection = storage.GetConnection(); - var ids = connection.GetAllItemsFromSet(GroupSheetsSet(groupId)); - var result = new List(); - foreach (var id in ids) - { - var state = await GetSheetAsync(id, cancellationToken); - if (state != null) - result.Add(state); - } - - return result; - } - - /// - public Task RemoveGroupAsync(string groupId, CancellationToken cancellationToken) - { - using var connection = storage.GetConnection(); - var sheetIds = connection.GetAllItemsFromSet(GroupSheetsSet(groupId)); - - using var tx = connection.CreateWriteTransaction(); - foreach (var sheetId in sheetIds) - { - tx.RemoveHash(SheetKeyPrefix + sheetId); - tx.RemoveHash(JobLogKeyPrefix + sheetId); - tx.TrimList(JobLogKeyPrefix + sheetId, 1, 0); - tx.RemoveFromSet(GroupSheetsSet(groupId), sheetId); - } - - tx.RemoveFromSet(ActiveGroupsSet, groupId); - tx.RemoveFromSet(AllGroupsSet, groupId); - tx.RemoveHash(GroupKeyPrefix + groupId); - tx.Commit(); - return Task.CompletedTask; - } - - /// - public async Task RemoveSheetAsync(string sheetId, CancellationToken cancellationToken) - { - var state = await GetSheetAsync(sheetId, cancellationToken); - using var connection = storage.GetConnection(); - using var tx = connection.CreateWriteTransaction(); - tx.RemoveHash(SheetKeyPrefix + sheetId); - tx.RemoveHash(JobLogKeyPrefix + sheetId); - tx.TrimList(JobLogKeyPrefix + sheetId, 1, 0); - if (state != null) - tx.RemoveFromSet(GroupSheetsSet(state.GroupId), sheetId); - tx.Commit(); - } - - private List GetJobLogsInternal(string jobId) - { - using var connection = storage.GetConnection(); - var key = JobLogKeyPrefix + jobId; - return TryReadListLogs(connection, key, out var logs) - ? logs - : GetLegacyJobLogs(connection, key); - } - - private bool TryAppendListLogs(IStorageConnection connection, IReadOnlyCollection entries) - { - if (connection is not JobStorageConnection jobConnection) - return false; - - using var tx = connection.CreateWriteTransaction(); - foreach (var group in entries.GroupBy(entry => entry.JobId)) - { - var key = JobLogKeyPrefix + group.Key; - TryMigrateLegacyLogs(jobConnection, connection, tx, key); - foreach (var entry in group) - tx.InsertToList(key, JsonSerializer.Serialize(entry, SerializerOptions)); - tx.TrimList(key, 0, MaxLogEntries - 1); - } - - tx.Commit(); - return true; - } - - private void TryMigrateLegacyLogs( - JobStorageConnection jobConnection, - IStorageConnection connection, - IWriteOnlyTransaction tx, - string key) - { - try - { - if (jobConnection.GetListCount(key) > 0) - return; - } - catch (NotSupportedException) - { - return; - } - - var legacyEntries = GetLegacyJobLogs(connection, key); - if (legacyEntries.Count == 0) - return; - - foreach (var legacyEntry in legacyEntries) - tx.InsertToList(key, JsonSerializer.Serialize(legacyEntry, SerializerOptions)); - - tx.RemoveHash(key); - } - - private static bool TryReadListLogs( - IStorageConnection connection, - string key, - out List logs) - { - logs = []; - if (connection is not JobStorageConnection jobConnection) - return false; - - List entries; - try - { - entries = jobConnection.GetRangeFromList(key, 0, MaxLogEntries - 1); - } - catch (NotSupportedException) - { - return false; - } - - if (entries.Count == 0) - return false; - - logs = new List(entries.Count); - for (var i = entries.Count - 1; i >= 0; i--) - { - var log = JsonSerializer.Deserialize(entries[i], SerializerOptions); - if (log != null) - logs.Add(log); - } - - return true; - } - - private static List GetLegacyJobLogs(IStorageConnection connection, string key) - { - var entries = connection.GetAllEntriesFromHash(key); - if (entries == null || !entries.TryGetValue("data", out var json)) - return []; - - var logs = JsonSerializer.Deserialize>(json, SerializerOptions); - return logs ?? []; - } - - private static string GroupSheetsSet(string groupId) - { - return $"slidegen:group:{groupId}:sheets"; - } - - private static bool IsActive(GroupStatus status) - { - return status is GroupStatus.Pending or GroupStatus.Running or GroupStatus.Paused; - } -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Infrastructure/Features/Jobs/Services/JobExecutor.cs b/backend/src/SlideGenerator.Infrastructure/Features/Jobs/Services/JobExecutor.cs deleted file mode 100644 index 35ab52ae..00000000 --- a/backend/src/SlideGenerator.Infrastructure/Features/Jobs/Services/JobExecutor.cs +++ /dev/null @@ -1,433 +0,0 @@ -using Microsoft.Extensions.Logging; -using SlideGenerator.Application.Features.Jobs.Contracts; -using SlideGenerator.Application.Features.Slides; -using SlideGenerator.Domain.Features.IO; -using SlideGenerator.Domain.Features.Jobs.Entities; -using SlideGenerator.Domain.Features.Jobs.Enums; -using SlideGenerator.Domain.Features.Jobs.Interfaces; -using SlideGenerator.Domain.Features.Jobs.Notifications; -using SlideGenerator.Domain.Features.Jobs.States; -using SlideGenerator.Infrastructure.Common.Base; -using SlideGenerator.Infrastructure.Features.Jobs.Hangfire; - -namespace SlideGenerator.Infrastructure.Features.Jobs.Services; - -/// -public class JobExecutor( - ILogger logger, - JobManager jobManager, - ISlideServices slideServices, - ISlideWorkingManager slideWorkingManager, - IJobNotifier jobNotifier, - IJobStateStore jobStateStore, - IFileSystem fileSystem) : Service(logger), IJobExecutor -{ - /// - [SheetJobDisplayName] - public async Task ExecuteJobAsync(string sheetId, CancellationToken cancellationToken) - { - if (!TryGetSheetAndGroup(sheetId, out var sheet, out var group) || sheet == null || group == null) - return; - - sheet.MarkExecuting(true); - var executionContext = new JobExecutionContext(); - - using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( - cancellationToken, sheet.CancellationTokenSource.Token); - var token = linkedCts.Token; - - var checkpoint = CreateCheckpoint(sheet); - - List? bufferedLogs; - - try - { - if (sheet.Status == SheetJobStatus.Paused) - { - await sheet.WaitIfPausedAsync(token); - token.ThrowIfCancellationRequested(); - } - - await StartJobAsync(sheet, group, sheetId); - if (!await EnsureOutputFileReadyAsync(sheet, group, sheetId)) - return; - await ProcessRowsAsync(sheet, group, sheetId, checkpoint, token, executionContext); - await CompleteJobAsync(sheet, group, sheetId); - } - catch (OperationCanceledException) - { - bufferedLogs = executionContext.BufferedLogs; - await HandleCancellationAsync(sheet, group, sheetId, bufferedLogs); - } - catch (Exception ex) - { - bufferedLogs = executionContext.BufferedLogs; - var activeRow = executionContext.ActiveRow; - await HandleFailureAsync(sheet, group, sheetId, ex, activeRow, bufferedLogs); - } - finally - { - sheet.MarkExecuting(false); - sheet.HangfireJobId = null; - if (sheet.Status is not SheetJobStatus.Pending and not SheetJobStatus.Running) - slideWorkingManager.RemoveWorkingPresentation(sheet.OutputPath); - } - } - - private bool TryGetSheetAndGroup(string sheetId, out JobSheet? sheet, out JobGroup? group) - { - sheet = jobManager.GetInternalSheet(sheetId); - if (sheet == null) - { - Logger.LogWarning("Sheet {SheetId} not found", sheetId); - group = null; - return false; - } - - group = jobManager.GetInternalGroup(sheet.GroupId); - if (group == null) - { - Logger.LogWarning("Group {GroupId} not found for job {JobId}{HangfireSuffix}", sheet.GroupId, - sheetId, FormatHangfireSuffix(sheet.HangfireJobId)); - return false; - } - - return true; - } - - private static JobCheckpoint CreateCheckpoint(JobSheet sheet) - { - return async (_, ct) => - { - await sheet.WaitIfPausedAsync(ct); - ct.ThrowIfCancellationRequested(); - }; - } - - private async Task StartJobAsync(JobSheet sheet, JobGroup group, string sheetId) - { - sheet.SetStatus(SheetJobStatus.Running); - await jobNotifier.NotifyJobStatusChanged(sheetId, SheetJobStatus.Running); - await PersistSheetStateAsync(sheet); - group.UpdateStatus(); - await PersistGroupStateAsync(group); - await jobNotifier.NotifyGroupStatusChanged(group.Id, group.Status); - } - - private async Task EnsureOutputFileReadyAsync(JobSheet sheet, JobGroup group, string sheetId) - { - if (sheet.CurrentRow == 0) - { - slideWorkingManager.RemoveWorkingPresentation(sheet.OutputPath); - fileSystem.CopyFile(group.Template.FilePath, sheet.OutputPath, true); - return true; - } - - if (fileSystem.FileExists(sheet.OutputPath)) - return true; - - sheet.SetStatus(SheetJobStatus.Failed, "Output file missing during resume."); - await jobNotifier.NotifyJobStatusChanged(sheetId, sheet.Status, sheet.ErrorMessage); - await PersistSheetStateAsync(sheet); - group.UpdateStatus(); - await PersistGroupStateAsync(group); - await jobNotifier.NotifyGroupStatusChanged(group.Id, group.Status); - jobManager.NotifySheetCompleted(sheetId); - return false; - } - - private async Task ProcessRowsAsync( - JobSheet sheet, - JobGroup group, - string sheetId, - JobCheckpoint checkpoint, - CancellationToken token, - JobExecutionContext context) - { - var startRow = sheet.NextRowIndex; - for (var rowNum = startRow; rowNum <= sheet.TotalRows; rowNum++) - { - await checkpoint(JobCheckpointStage.BeforeRow, token); - - context.ActiveRow = rowNum; - var buffer = new List(4); - context.BufferedLogs = buffer; - await LogRowStartedAsync(sheet, rowNum, buffer); - - var rowData = sheet.Worksheet.GetRow(rowNum); - var result = await slideServices.ProcessRowAsync( - sheet.OutputPath, - sheet.TextConfigs, - sheet.ImageConfigs, - rowData, - checkpoint, - token); - - await LogTextReplacementsAsync(sheet, rowNum, result.TextReplacements, buffer); - await LogImageReplacementsAsync(sheet, rowNum, result.ImageReplacements, buffer); - await LogRowCompletedAsync(sheet, rowNum, result, buffer); - - if (result.ImageErrorCount > 0) - await LogRowWarningsAsync(sheet, rowNum, result, buffer); - - await FlushLogsAsync(context.BufferedLogs); - context.BufferedLogs = null; - - sheet.UpdateProgress(rowNum); - await checkpoint(JobCheckpointStage.BeforePersistState, token); - await PersistSheetStateAsync(sheet); - await jobNotifier.NotifyJobProgress(sheetId, rowNum, sheet.TotalRows, sheet.Progress, sheet.ErrorCount); - await jobNotifier.NotifyGroupProgress(group.Id, group.Progress, group.ErrorCount); - } - } - - private async Task LogRowStartedAsync(JobSheet sheet, int rowNum, List buffer) - { - await StoreAndNotifyLogAsync(new JobEvent( - sheet.Id, - JobEventScope.Sheet, - DateTimeOffset.UtcNow, - "Info", - $"Processing row {rowNum}", - new Dictionary - { - ["row"] = rowNum, - ["rowStatus"] = "processing" - }), buffer); - } - - private async Task LogTextReplacementsAsync( - JobSheet sheet, - int rowNum, - IReadOnlyCollection details, - List buffer) - { - foreach (var detail in details) - await StoreAndNotifyLogAsync(new JobEvent( - sheet.Id, - JobEventScope.Sheet, - DateTimeOffset.UtcNow, - "Info", - $"Row {rowNum} text -> shape {detail.ShapeId}: {detail.Placeholder} = {detail.Value}", - new Dictionary - { - ["row"] = rowNum, - ["shapeId"] = detail.ShapeId, - ["placeholder"] = detail.Placeholder, - ["value"] = detail.Value, - ["kind"] = "text" - }), buffer); - } - - private async Task LogImageReplacementsAsync( - JobSheet sheet, - int rowNum, - IReadOnlyCollection details, - List buffer) - { - foreach (var detail in details) - await StoreAndNotifyLogAsync(new JobEvent( - sheet.Id, - JobEventScope.Sheet, - DateTimeOffset.UtcNow, - "Info", - $"Row {rowNum} image -> shape {detail.ShapeId}: {detail.Source}", - new Dictionary - { - ["row"] = rowNum, - ["shapeId"] = detail.ShapeId, - ["source"] = detail.Source, - ["kind"] = "image" - }), buffer); - } - - private async Task LogRowCompletedAsync( - JobSheet sheet, - int rowNum, - RowProcessResult result, - List buffer) - { - await StoreAndNotifyLogAsync(new JobEvent( - sheet.Id, - JobEventScope.Sheet, - DateTimeOffset.UtcNow, - "Info", - $"Row {rowNum} completed (text: {result.TextReplacementCount}, images: {result.ImageReplacementCount}, image errors: {result.ImageErrorCount})", - new Dictionary - { - ["row"] = rowNum, - ["rowStatus"] = "completed", - ["textReplacements"] = result.TextReplacementCount, - ["imageReplacements"] = result.ImageReplacementCount, - ["imageErrors"] = result.ImageErrorCount - }), buffer); - } - - private async Task LogRowWarningsAsync( - JobSheet sheet, - int rowNum, - RowProcessResult result, - List buffer) - { - sheet.RegisterRowError(rowNum, string.Join("; ", result.Errors)); - var detail = string.Join("; ", result.Errors); - var warningMessage = string.IsNullOrWhiteSpace(detail) - ? $"Row {rowNum} completed with {result.ImageErrorCount} image errors" - : $"Row {rowNum} completed with {result.ImageErrorCount} image errors: {detail}"; - await StoreAndNotifyLogAsync(new JobEvent( - sheet.Id, - JobEventScope.Sheet, - DateTimeOffset.UtcNow, - "Warning", - warningMessage, - new Dictionary - { - ["row"] = rowNum, - ["rowStatus"] = "warning", - ["errors"] = result.Errors - }), buffer); - } - - private async Task CompleteJobAsync(JobSheet sheet, JobGroup group, string sheetId) - { - slideServices.RemoveFirstSlide(sheet.OutputPath); - - sheet.SetStatus(SheetJobStatus.Completed); - await PersistSheetStateAsync(sheet); - await jobNotifier.NotifyJobStatusChanged(sheetId, SheetJobStatus.Completed); - Logger.LogInformation("Job {JobId}{HangfireSuffix} completed successfully", sheetId, - FormatHangfireSuffix(sheet.HangfireJobId)); - - group.UpdateStatus(); - await PersistGroupStateAsync(group); - await jobNotifier.NotifyGroupProgress(group.Id, group.Progress, group.ErrorCount); - await jobNotifier.NotifyGroupStatusChanged(group.Id, group.Status); - - jobManager.NotifySheetCompleted(sheetId); - } - - private async Task HandleCancellationAsync( - JobSheet sheet, - JobGroup group, - string sheetId, - List? bufferedLogs) - { - await FlushLogsAsync(bufferedLogs); - - if (sheet.Status != SheetJobStatus.Cancelled) - sheet.SetStatus(SheetJobStatus.Paused); - await PersistSheetStateAsync(sheet); - await jobNotifier.NotifyJobStatusChanged(sheetId, sheet.Status); - Logger.LogInformation("Job {JobId}{HangfireSuffix} was paused/cancelled", sheetId, - FormatHangfireSuffix(sheet.HangfireJobId)); - - group.UpdateStatus(); - await PersistGroupStateAsync(group); - await jobNotifier.NotifyGroupStatusChanged(group.Id, group.Status); - - if (sheet.Status == SheetJobStatus.Cancelled) - jobManager.NotifySheetCompleted(sheetId); - } - - private async Task HandleFailureAsync( - JobSheet sheet, - JobGroup group, - string sheetId, - Exception ex, - int? activeRow, - List? bufferedLogs) - { - await FlushLogsAsync(bufferedLogs); - - sheet.SetStatus(SheetJobStatus.Failed, ex.Message); - await PersistSheetStateAsync(sheet); - await jobNotifier.NotifyJobError(sheetId, ex.Message); - await jobNotifier.NotifyJobStatusChanged(sheetId, SheetJobStatus.Failed, ex.Message); - await StoreAndNotifyLogAsync(new JobEvent( - sheet.Id, - JobEventScope.Sheet, - DateTimeOffset.UtcNow, - "Error", - ex.Message, - new Dictionary - { - ["row"] = activeRow, - ["rowStatus"] = "error" - })); - Logger.LogError(ex, "Job {JobId}{HangfireSuffix} failed", sheetId, - FormatHangfireSuffix(sheet.HangfireJobId)); - - group.UpdateStatus(); - await PersistGroupStateAsync(group); - await jobNotifier.NotifyGroupStatusChanged(group.Id, group.Status); - - jobManager.NotifySheetCompleted(sheetId); - } - - private async Task PersistSheetStateAsync(JobSheet sheet) - { - var state = new SheetJobState( - sheet.Id, - sheet.GroupId, - sheet.SheetName, - sheet.OutputPath, - sheet.Status, - sheet.NextRowIndex, - sheet.TotalRows, - sheet.ErrorCount, - sheet.ErrorMessage, - sheet.TextConfigs, - sheet.ImageConfigs); - - await jobStateStore.SaveSheetAsync(state, CancellationToken.None); - } - - private async Task PersistGroupStateAsync(JobGroup group) - { - var state = new GroupJobState( - group.Id, - group.Workbook.FilePath, - group.Template.FilePath, - group.OutputFolder.FullName, - group.Status, - group.CreatedAt, - group.InternalJobs.Keys.ToList(), - group.ErrorCount); - - await jobStateStore.SaveGroupAsync(state, CancellationToken.None); - } - - private async Task StoreAndNotifyLogAsync(JobEvent jobEvent, List? buffer = null) - { - var entry = new JobLogEntry( - jobEvent.JobId, - jobEvent.Timestamp, - jobEvent.Level, - jobEvent.Message, - jobEvent.Data); - if (buffer == null) - await jobStateStore.AppendJobLogAsync(entry, CancellationToken.None); - else - buffer.Add(entry); - await jobNotifier.NotifyLog(jobEvent); - } - - private Task FlushLogsAsync(List? buffer) - { - if (buffer == null || buffer.Count == 0) - return Task.CompletedTask; - - return jobStateStore.AppendJobLogsAsync(buffer, CancellationToken.None); - } - - private static string FormatHangfireSuffix(string? hangfireJobId) - { - return string.IsNullOrWhiteSpace(hangfireJobId) ? string.Empty : $" (#{hangfireJobId})"; - } - - private sealed class JobExecutionContext - { - public int? ActiveRow { get; set; } - public List? BufferedLogs { get; set; } - } -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Infrastructure/Features/Jobs/Services/JobManager.cs b/backend/src/SlideGenerator.Infrastructure/Features/Jobs/Services/JobManager.cs deleted file mode 100644 index 85585c0f..00000000 --- a/backend/src/SlideGenerator.Infrastructure/Features/Jobs/Services/JobManager.cs +++ /dev/null @@ -1,290 +0,0 @@ -using Hangfire; -using Microsoft.Extensions.Logging; -using SlideGenerator.Application.Features.Jobs.Contracts; -using SlideGenerator.Application.Features.Jobs.Contracts.Collections; -using SlideGenerator.Application.Features.Sheets; -using SlideGenerator.Application.Features.Slides; -using SlideGenerator.Domain.Features.IO; -using SlideGenerator.Domain.Features.Jobs.Entities; -using SlideGenerator.Domain.Features.Jobs.Enums; -using SlideGenerator.Domain.Features.Jobs.Interfaces; -using SlideGenerator.Domain.Features.Jobs.States; -using SlideGenerator.Domain.Features.Sheets.Interfaces; -using SlideGenerator.Domain.Features.Slides; -using SlideGenerator.Domain.Features.Slides.Components; -using SlideGenerator.Infrastructure.Common.Base; -using SlideGenerator.Infrastructure.Features.Jobs.Models; - -namespace SlideGenerator.Infrastructure.Features.Jobs.Services; - -/// -public class JobManager : Service, IJobManager -{ - private readonly ActiveJobCollection _active; - private readonly IBackgroundJobClient _backgroundJobClient; - private readonly CompletedJobCollection _completed; - private readonly IJobStateStore _jobStateStore; - private readonly ISheetService _sheetService; - private readonly ISlideTemplateManager _slideTemplateManager; - - public JobManager( - ILogger logger, - ILoggerFactory loggerFactory, - ISheetService sheetService, - ISlideTemplateManager slideTemplateManager, - IBackgroundJobClient backgroundJobClient, - IJobStateStore jobStateStore, - IJobNotifier jobNotifier, - IFileSystem fileSystem) : base(logger) - { - _sheetService = sheetService; - _slideTemplateManager = slideTemplateManager; - _backgroundJobClient = backgroundJobClient; - _jobStateStore = jobStateStore; - - _completed = new CompletedJobCollection( - loggerFactory.CreateLogger(), - jobStateStore); - - _active = new ActiveJobCollection( - loggerFactory.CreateLogger(), - sheetService, - slideTemplateManager, - backgroundJobClient, - jobStateStore, - fileSystem, - jobNotifier, - group => _completed.AddGroup(group)); - } - - #region Restore - - /// - /// Restores unfinished jobs from persisted state. - /// - public async Task RestoreAsync(CancellationToken cancellationToken) - { - Logger.LogInformation("Starting job restoration from persisted state"); - - var groupStates = await _jobStateStore.GetAllGroupsAsync(cancellationToken); - if (groupStates.Count == 0) - groupStates = [.. await _jobStateStore.GetActiveGroupsAsync(cancellationToken)]; - - Logger.LogDebug("Found {GroupCount} persisted job groups to restore", groupStates.Count); - - foreach (var groupState in groupStates) - { - var sheetStates = await _jobStateStore.GetSheetsByGroupAsync(groupState.Id, cancellationToken); - if (sheetStates.Count == 0) continue; - - if (!IsActiveStatus(groupState.Status)) - { - Logger.LogDebug("Restoring completed group {GroupId} with status {Status}", - groupState.Id, groupState.Status); - RestoreCompletedGroup(groupState, sheetStates); - continue; - } - - Logger.LogInformation("Restoring active group {GroupId} with {SheetCount} sheets", - groupState.Id, sheetStates.Count); - - var workbook = _sheetService.OpenFile(groupState.WorkbookPath); - _slideTemplateManager.AddTemplate(groupState.TemplatePath); - var template = _slideTemplateManager.GetTemplate(groupState.TemplatePath); - var outputFolder = new DirectoryInfo(groupState.OutputFolderPath); - - var textConfigs = sheetStates[0].TextConfigs; - var imageConfigs = sheetStates[0].ImageConfigs; - - var group = new JobGroup(workbook, template, outputFolder, textConfigs, imageConfigs, groupState.CreatedAt, - groupState.Id); - - // Force status to Paused if it was Running or Pending - group.SetStatus(groupState.Status is GroupStatus.Running or GroupStatus.Pending - ? GroupStatus.Paused - : groupState.Status); - - foreach (var sheetState in sheetStates) - { - var sheet = group.AddJob(sheetState.SheetName, sheetState.OutputPath, sheetState.Id); - sheet.UpdateProgress(Math.Max(0, sheetState.NextRowIndex - 1)); - sheet.RestoreErrorCount(sheetState.ErrorCount); - - // Force status to Paused if it was Running/Pending/Paused (ensure pause signal is set). - if (sheetState.Status is SheetJobStatus.Running or SheetJobStatus.Pending or SheetJobStatus.Paused) - sheet.Pause(); - else - sheet.SetStatus(sheetState.Status, sheetState.ErrorMessage); - } - - _active.RestoreGroup(group); - } - } - - #endregion - - #region Collections - - /// - public IActiveJobCollection Active => _active; - - /// - public ICompletedJobCollection Completed => _completed; - - #endregion - - #region Cross-Collection Query - - /// - public IJobGroup? GetGroup(string groupId) - { - return _active.GetGroup(groupId) ?? _completed.GetGroup(groupId); - } - - /// - public IJobSheet? GetSheet(string sheetId) - { - return _active.GetSheet(sheetId) ?? _completed.GetSheet(sheetId); - } - - /// - public IReadOnlyDictionary GetAllGroups() - { - var result = new Dictionary(); - foreach (var kv in _active.GetAllGroups()) - result[kv.Key] = kv.Value; - foreach (var kv in _completed.GetAllGroups()) - result[kv.Key] = kv.Value; - return result; - } - - #endregion - - #region Internal Methods (for JobExecutor) - - internal JobSheet? GetInternalSheet(string sheetId) - { - return _active.GetInternalSheet(sheetId); - } - - internal JobGroup? GetInternalGroup(string groupId) - { - return _active.GetInternalGroup(groupId); - } - - internal void NotifySheetCompleted(string sheetId) - { - _active.NotifySheetCompleted(sheetId); - } - - #endregion - - #region Restore Helpers - - private void RestoreCompletedGroup(GroupJobState groupState, IReadOnlyList sheetStates) - { - var workbook = new PersistedSheetBook(groupState.WorkbookPath, sheetStates); - var template = new PersistedTemplatePresentation(groupState.TemplatePath); - var outputFolder = new DirectoryInfo(groupState.OutputFolderPath); - - var textConfigs = sheetStates[0].TextConfigs; - var imageConfigs = sheetStates[0].ImageConfigs; - - var group = new JobGroup(workbook, template, outputFolder, textConfigs, imageConfigs, groupState.CreatedAt, - groupState.Id); - group.SetStatus(groupState.Status); - - foreach (var sheetState in sheetStates) - { - var sheet = group.AddJob(sheetState.SheetName, sheetState.OutputPath, sheetState.Id); - sheet.UpdateProgress(Math.Max(0, sheetState.NextRowIndex - 1)); - sheet.RestoreErrorCount(sheetState.ErrorCount); - sheet.SetStatus(sheetState.Status, sheetState.ErrorMessage); - } - - group.UpdateStatus(); - _completed.AddGroup(group); - } - - private static bool IsActiveStatus(GroupStatus status) - { - return status is GroupStatus.Pending or GroupStatus.Running or GroupStatus.Paused; - } - - private sealed class PersistedSheetBook : ISheetBook - { - public PersistedSheetBook(string filePath, IEnumerable sheetStates) - { - FilePath = filePath; - Name = Path.GetFileNameWithoutExtension(filePath); - - Worksheets = sheetStates - .GroupBy(state => state.SheetName) - .ToDictionary( - group => group.Key, - group => (ISheet)new PersistedSheet(group.Key, group.First().TotalRows)); - } - - public string FilePath { get; } - - public string? Name { get; } - - public IReadOnlyDictionary Worksheets { get; } - - public IReadOnlyDictionary GetSheetsInfo() - { - return Worksheets.ToDictionary(kv => kv.Key, kv => kv.Value.RowCount); - } - - public void Dispose() - { - } - } - - private sealed class PersistedSheet(string name, int rowCount) : ISheet - { - public string Name { get; } = name; - - public IReadOnlyList Headers { get; } = []; - - public int RowCount { get; } = rowCount; - - public Dictionary GetRow(int rowNumber) - { - return new Dictionary(); - } - - public List> GetAllRows() - { - return []; - } - } - - private sealed class PersistedTemplatePresentation(string filePath) : ITemplatePresentation - { - public string FilePath { get; } = filePath; - - public int SlideCount => 1; - - public Dictionary GetAllImageShapes() - { - return new Dictionary(); - } - - public IReadOnlyList GetAllShapes() - { - return []; - } - - public IReadOnlyCollection GetAllTextPlaceholders() - { - return []; - } - - public void Dispose() - { - } - } - - #endregion -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Infrastructure/Features/Jobs/Services/JobNotifier.cs b/backend/src/SlideGenerator.Infrastructure/Features/Jobs/Services/JobNotifier.cs deleted file mode 100644 index 45fb9ade..00000000 --- a/backend/src/SlideGenerator.Infrastructure/Features/Jobs/Services/JobNotifier.cs +++ /dev/null @@ -1,78 +0,0 @@ -using Microsoft.AspNetCore.SignalR; -using Microsoft.Extensions.Logging; -using SlideGenerator.Application.Features.Jobs; -using SlideGenerator.Application.Features.Jobs.Contracts; -using SlideGenerator.Application.Features.Slides.DTOs.Notifications; -using SlideGenerator.Domain.Features.Jobs.Enums; -using SlideGenerator.Domain.Features.Jobs.Notifications; -using SlideGenerator.Infrastructure.Common.Base; - -namespace SlideGenerator.Infrastructure.Features.Jobs.Services; - -/// -public class JobNotifier( - ILogger> logger, - IHubContext hubContext) : Service(logger), IJobNotifier - where TTHub : Hub -{ - private const string ReceiveMethod = "ReceiveNotification"; - - /// - public async Task NotifyJobProgress(string jobId, int currentRow, int totalRows, float progress, int errorCount) - { - var notification = new JobProgressNotification(jobId, currentRow, totalRows, progress, errorCount, - DateTimeOffset.UtcNow); - await hubContext.Clients.Group(JobSignalRGroups.SheetGroup(jobId)) - .SendAsync(ReceiveMethod, notification); - } - - /// - public async Task NotifyJobStatusChanged(string jobId, SheetJobStatus status, string? message = null) - { - var notification = new JobStatusNotification(jobId, status, message, DateTimeOffset.UtcNow); - await hubContext.Clients.Group(JobSignalRGroups.SheetGroup(jobId)) - .SendAsync(ReceiveMethod, notification); - } - - /// - public async Task NotifyJobError(string jobId, string error) - { - var notification = new JobErrorNotification(jobId, error, DateTimeOffset.UtcNow); - await hubContext.Clients.Group(JobSignalRGroups.SheetGroup(jobId)) - .SendAsync(ReceiveMethod, notification); - } - - /// - public async Task NotifyGroupProgress(string groupId, float progress, int errorCount) - { - var notification = new GroupProgressNotification(groupId, progress, errorCount, DateTimeOffset.UtcNow); - await hubContext.Clients.Group(JobSignalRGroups.GroupGroup(groupId)) - .SendAsync(ReceiveMethod, notification); - } - - /// - public async Task NotifyGroupStatusChanged(string groupId, GroupStatus status, string? message = null) - { - var notification = new GroupStatusNotification(groupId, status, message, DateTimeOffset.UtcNow); - await hubContext.Clients.Group(JobSignalRGroups.GroupGroup(groupId)) - .SendAsync(ReceiveMethod, notification); - } - - /// - public async Task NotifyLog(JobEvent jobEvent) - { - var notification = new JobLogNotification(jobEvent.JobId, jobEvent.Level, jobEvent.Message, - jobEvent.Timestamp, jobEvent.Data); - var groupName = jobEvent.Scope switch - { - JobEventScope.Group => JobSignalRGroups.GroupGroup(jobEvent.JobId), - JobEventScope.Sheet => JobSignalRGroups.SheetGroup(jobEvent.JobId), - _ => string.Empty - }; - - if (string.IsNullOrEmpty(groupName)) - return; - - await hubContext.Clients.Group(groupName).SendAsync(ReceiveMethod, notification); - } -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Infrastructure/Features/Jobs/Services/JobRestoreHostedService.cs b/backend/src/SlideGenerator.Infrastructure/Features/Jobs/Services/JobRestoreHostedService.cs deleted file mode 100644 index 3bbc475f..00000000 --- a/backend/src/SlideGenerator.Infrastructure/Features/Jobs/Services/JobRestoreHostedService.cs +++ /dev/null @@ -1,24 +0,0 @@ -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; - -namespace SlideGenerator.Infrastructure.Features.Jobs.Services; - -/// -/// Restores unfinished jobs from persisted state on startup. -/// -public sealed class JobRestoreHostedService(JobManager jobManager, ILogger logger) - : IHostedService -{ - /// - public async Task StartAsync(CancellationToken cancellationToken) - { - await jobManager.RestoreAsync(cancellationToken); - logger.LogInformation("Job state restoration completed"); - } - - /// - public Task StopAsync(CancellationToken cancellationToken) - { - return Task.CompletedTask; - } -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Infrastructure/Features/Sheets/Adapters/WorkbookAdapter.cs b/backend/src/SlideGenerator.Infrastructure/Features/Sheets/Adapters/WorkbookAdapter.cs deleted file mode 100644 index cd98f905..00000000 --- a/backend/src/SlideGenerator.Infrastructure/Features/Sheets/Adapters/WorkbookAdapter.cs +++ /dev/null @@ -1,33 +0,0 @@ -using SlideGenerator.Domain.Features.Sheets.Interfaces; -using CoreWorkbook = SlideGenerator.Framework.Sheet.Models.Workbook; - -namespace SlideGenerator.Infrastructure.Features.Sheets.Adapters; - -/// -/// Adapter to convert to . -/// -internal sealed class WorkbookAdapter : ISheetBook -{ - private readonly CoreWorkbook _workbook; - - public WorkbookAdapter(CoreWorkbook workbook) - { - _workbook = workbook; - Worksheets = workbook.Worksheets.ToDictionary( - kv => kv.Key, ISheet (kv) => new WorksheetAdapter(kv.Value)); - } - - public string FilePath => _workbook.FilePath; - public string? Name => _workbook.Name; - public IReadOnlyDictionary Worksheets { get; } - - public IReadOnlyDictionary GetSheetsInfo() - { - return _workbook.GetWorksheetsInfo(); - } - - public void Dispose() - { - _workbook.Dispose(); - } -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Infrastructure/Features/Sheets/Adapters/WorksheetAdapter.cs b/backend/src/SlideGenerator.Infrastructure/Features/Sheets/Adapters/WorksheetAdapter.cs deleted file mode 100644 index 08277cfd..00000000 --- a/backend/src/SlideGenerator.Infrastructure/Features/Sheets/Adapters/WorksheetAdapter.cs +++ /dev/null @@ -1,24 +0,0 @@ -using SlideGenerator.Domain.Features.Sheets.Interfaces; -using CoreWorksheet = SlideGenerator.Framework.Sheet.Contracts.IWorksheet; - -namespace SlideGenerator.Infrastructure.Features.Sheets.Adapters; - -/// -/// Adapter to convert SlideGenerator.Framework.Sheet.Contracts.IWorksheet to Domain.Sheet.Interfaces.ISheet. -/// -internal sealed class WorksheetAdapter(CoreWorksheet worksheet) : ISheet -{ - public string Name => worksheet.Name; - public IReadOnlyList Headers => worksheet.Headers; - public int RowCount => worksheet.RowCount; - - public Dictionary GetRow(int rowNumber) - { - return worksheet.GetRow(rowNumber); - } - - public List> GetAllRows() - { - return worksheet.GetAllRows(); - } -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Infrastructure/Features/Sheets/Exceptions/SheetNotFound.cs b/backend/src/SlideGenerator.Infrastructure/Features/Sheets/Exceptions/SheetNotFound.cs deleted file mode 100644 index e6724c71..00000000 --- a/backend/src/SlideGenerator.Infrastructure/Features/Sheets/Exceptions/SheetNotFound.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace SlideGenerator.Infrastructure.Features.Sheets.Exceptions; - -/// -/// Exception thrown when a sheet is not found in a workbook. -/// -public class SheetNotFound(string sheetName, string? workbookPath = null) - : KeyNotFoundException( - $"Table '{sheetName}' not found{(workbookPath != null ? $" in workbook '{workbookPath}'" : "")}.") -{ - public string SheetName { get; } = sheetName; - public string? WorkbookPath { get; } = workbookPath; -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Infrastructure/Features/Sheets/Services/SheetService.cs b/backend/src/SlideGenerator.Infrastructure/Features/Sheets/Services/SheetService.cs deleted file mode 100644 index 8d0a2bf3..00000000 --- a/backend/src/SlideGenerator.Infrastructure/Features/Sheets/Services/SheetService.cs +++ /dev/null @@ -1,44 +0,0 @@ -using Microsoft.Extensions.Logging; -using SlideGenerator.Application.Features.Sheets; -using SlideGenerator.Domain.Features.Sheets.Interfaces; -using SlideGenerator.Infrastructure.Common.Base; -using SlideGenerator.Infrastructure.Features.Sheets.Adapters; -using SlideGenerator.Infrastructure.Features.Sheets.Exceptions; -using CoreWorkbook = SlideGenerator.Framework.Sheet.Models.Workbook; - -namespace SlideGenerator.Infrastructure.Features.Sheets.Services; - -using RowContent = Dictionary; - -/// -/// Sheet processing service implementation. -/// -public class SheetService(ILogger logger) : Service(logger), - ISheetService -{ - public ISheetBook OpenFile(string filePath) - { - Logger.LogInformation("Opening sheet file: {FilePath}", filePath); - var workbook = new CoreWorkbook(filePath); - return new WorkbookAdapter(workbook); - } - - public IReadOnlyDictionary GetSheetsInfo(ISheetBook group) - { - return group.GetSheetsInfo(); - } - - public IReadOnlyList GetHeaders(ISheetBook group, string tableName) - { - return !group.Worksheets.TryGetValue(tableName, out var table) - ? throw new SheetNotFound(tableName, group.FilePath) - : table.Headers; - } - - public RowContent GetRow(ISheetBook group, string tableName, int rowNumber) - { - return !group.Worksheets.TryGetValue(tableName, out var table) - ? throw new SheetNotFound(tableName, group.FilePath) - : table.GetRow(rowNumber); - } -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Infrastructure/Features/Slides/Adapters/TemplatePresentationAdapter.cs b/backend/src/SlideGenerator.Infrastructure/Features/Slides/Adapters/TemplatePresentationAdapter.cs deleted file mode 100644 index da62fd35..00000000 --- a/backend/src/SlideGenerator.Infrastructure/Features/Slides/Adapters/TemplatePresentationAdapter.cs +++ /dev/null @@ -1,72 +0,0 @@ -using SlideGenerator.Domain.Features.Slides; -using SlideGenerator.Domain.Features.Slides.Components; -using SlideGenerator.Framework.Slide; -using CoreTemplatePresentation = SlideGenerator.Framework.Slide.Models.TemplatePresentation; -using Picture = DocumentFormat.OpenXml.Drawing.Picture; -using Presentation = SlideGenerator.Framework.Slide.Models.Presentation; -using Shape = DocumentFormat.OpenXml.Presentation.Shape; - -namespace SlideGenerator.Infrastructure.Features.Slides.Adapters; - -/// -/// Adapter to convert SlideGenerator.Framework.Slide.Models.TemplatePresentation to -/// Domain.Slide.Interfaces.ITemplatePresentation. -/// -internal sealed class TemplatePresentationAdapter(CoreTemplatePresentation presentation) - : ITemplatePresentation -{ - public void Dispose() - { - presentation.Dispose(); - } - - public string FilePath => presentation.FilePath; - - public int SlideCount => presentation.SlideCount; - - public Dictionary GetAllImageShapes() - { - var coreShapes = presentation.GetAllPreviewImageShapes(); - return coreShapes.ToDictionary( - kv => kv.Key, - kv => new ImagePreview(kv.Value.Name, kv.Value.ImageBytes)); - } - - public IReadOnlyList GetAllShapes() - { - var slidePart = presentation.GetSlidePart(); - if (slidePart == null) return []; - - var previews = presentation.GetAllPreviewImageShapes(); - var shapes = new List(previews.Count); - - foreach (var (id, preview) in previews) - { - var picture = Presentation.GetPictureById(slidePart, id); - if (picture != null) - { - var name = picture.NonVisualPictureProperties?.NonVisualDrawingProperties?.Name?.Value - ?? preview.Name; - shapes.Add(new ShapeInfo(id, name, nameof(Picture), true)); - continue; - } - - var shape = Presentation.GetShapeById(slidePart, id); - var shapeName = shape?.NonVisualShapeProperties?.NonVisualDrawingProperties?.Name?.Value - ?? preview.Name; - shapes.Add(new ShapeInfo(id, shapeName, nameof(Shape), true)); - } - - return shapes; - } - - public IReadOnlyCollection GetAllTextPlaceholders() - { - var slidePart = presentation.GetSlidePart(); - if (slidePart == null) return Array.Empty(); - - return TextReplacer.ScanPlaceholders(slidePart) - .OrderBy(value => value, StringComparer.OrdinalIgnoreCase) - .ToArray(); - } -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Infrastructure/Features/Slides/Adapters/WorkingPresentationAdapter.cs b/backend/src/SlideGenerator.Infrastructure/Features/Slides/Adapters/WorkingPresentationAdapter.cs deleted file mode 100644 index 716bf362..00000000 --- a/backend/src/SlideGenerator.Infrastructure/Features/Slides/Adapters/WorkingPresentationAdapter.cs +++ /dev/null @@ -1,31 +0,0 @@ -using SlideGenerator.Domain.Features.Slides; -using CoreWorkingPresentation = SlideGenerator.Framework.Slide.Models.WorkingPresentation; - -namespace SlideGenerator.Infrastructure.Features.Slides.Adapters; - -/// -/// Adapter to convert SlideGenerator.Framework.Slide.Models.WorkingPresentation to -/// Domain.Slide.Interfaces.IWorkingPresentation. -/// -internal sealed class WorkingPresentationAdapter(CoreWorkingPresentation presentation) - : IWorkingPresentation -{ - public void Dispose() - { - presentation.Dispose(); - } - - public string FilePath => presentation.FilePath; - - public int SlideCount => presentation.SlideCount; - - public void RemoveSlide(int position) - { - presentation.RemoveSlide(position); - } - - public void Save() - { - presentation.Save(); - } -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Infrastructure/Features/Slides/Exceptions/PresentationNotOpened.cs b/backend/src/SlideGenerator.Infrastructure/Features/Slides/Exceptions/PresentationNotOpened.cs deleted file mode 100644 index dbab725e..00000000 --- a/backend/src/SlideGenerator.Infrastructure/Features/Slides/Exceptions/PresentationNotOpened.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace SlideGenerator.Infrastructure.Features.Slides.Exceptions; - -/// -/// Exception thrown when a presentation is not opened. -/// -public class PresentationNotOpened(string filepath) - : InvalidOperationException("The presentation at the specified filepath is not open: " + filepath); \ No newline at end of file diff --git a/backend/src/SlideGenerator.Infrastructure/Features/Slides/Services/SlideServices.cs b/backend/src/SlideGenerator.Infrastructure/Features/Slides/Services/SlideServices.cs deleted file mode 100644 index 03baf0ea..00000000 --- a/backend/src/SlideGenerator.Infrastructure/Features/Slides/Services/SlideServices.cs +++ /dev/null @@ -1,292 +0,0 @@ -using DocumentFormat.OpenXml.Packaging; -using Microsoft.Extensions.Logging; -using SlideGenerator.Application.Features.Configs; -using SlideGenerator.Application.Features.Images; -using SlideGenerator.Application.Features.Slides; -using SlideGenerator.Domain.Features.Downloads; -using SlideGenerator.Domain.Features.Jobs.Components; -using SlideGenerator.Framework.Cloud; -using SlideGenerator.Framework.Cloud.Exceptions; -using SlideGenerator.Framework.Slide; -using SlideGenerator.Infrastructure.Common.Base; -using SlideGenerator.Infrastructure.Common.Utilities; -using SlideGenerator.Infrastructure.Features.Images.Exceptions; -using Path = System.IO.Path; -using Presentation = SlideGenerator.Framework.Slide.Models.Presentation; - -namespace SlideGenerator.Infrastructure.Features.Slides.Services; - -using ReplaceInstructions = Dictionary; -using RowContent = Dictionary; - -public class SlideServices( - ILogger logger, - IDownloadClient downloadClient, - IImageService imageService, - SlideWorkingManager slideWorkingManager, - IHttpClientFactory httpClientFactory) : Service(logger), ISlideServices -{ - public async Task ProcessRowAsync( - string presentationPath, - JobTextConfig[] textConfigs, - JobImageConfig[] imageConfigs, - RowContent rowData, - JobCheckpoint checkpoint, - CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - - slideWorkingManager.GetOrAddWorkingPresentation(presentationPath); - var newSlide = slideWorkingManager.CopyFirstSlideToLast(presentationPath); - - var textResult = - await ProcessTextReplacementsAsync(newSlide, rowData, textConfigs, checkpoint, cancellationToken); - var imageResult = await ProcessImageReplacementsAsync( - newSlide, - rowData, - imageConfigs, - checkpoint, - cancellationToken); - return new RowProcessResult( - textResult.Count, - imageResult.Count, - imageResult.ErrorCount, - imageResult.Errors, - textResult.Details, - imageResult.Details); - } - - public void RemoveFirstSlide(string presentationPath) - { - presentationPath = Path.GetFullPath(presentationPath); - var presentation = slideWorkingManager.GetWorkingPresentation(presentationPath); - - if (presentation.SlideCount <= 1) - { - Logger.LogWarning("Skip removing first slide for {FilePath} because slide count is {SlideCount}", - presentationPath, presentation.SlideCount); - return; - } - - presentation.RemoveSlide(1); - presentation.Save(); - Logger.LogInformation("Removed template slide from {FilePath}", presentationPath); - } - - private static async Task ProcessTextReplacementsAsync( - SlidePart slidePart, - RowContent rowData, - JobTextConfig[] textConfigs, - JobCheckpoint checkpoint, - CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - - var replacements = new ReplaceInstructions(); - foreach (var config in textConfigs) - foreach (var header in config.Columns) - { - if (!rowData.TryGetValue(header, out var value) || string.IsNullOrWhiteSpace(value)) continue; - replacements[config.Pattern] = value; - break; - } - - if (replacements.Count == 0) - return new TextReplacementOutcome(0, []); - - await checkpoint(JobCheckpointStage.BeforeSlideUpdate, cancellationToken); - var (replacedCount, internalDetails) = await TextReplacer.ReplaceAsync(slidePart, replacements); - await checkpoint(JobCheckpointStage.AfterSlideUpdate, cancellationToken); - - var details = internalDetails - .Select(d => new TextReplacementDetail(d.ShapeId, d.Placeholder, d.Value)) - .ToList(); - - return new TextReplacementOutcome((int)replacedCount, details); - } - - private async Task ProcessImageReplacementsAsync( - SlidePart slidePart, - RowContent rowData, - JobImageConfig[] imageConfigs, - JobCheckpoint checkpoint, - CancellationToken cancellationToken) - { - var errors = new List(); - var details = new List(); - var successCount = 0; - - var slideLock = new object(); - - await Parallel.ForEachAsync(imageConfigs, new ParallelOptions - { - CancellationToken = cancellationToken, - MaxDegreeOfParallelism = Math.Max(1, Environment.ProcessorCount / 2) - }, async (config, ct) => - { - var imageSource = GetImageSourceFromRowData(rowData, config.Columns); - if (string.IsNullOrWhiteSpace(imageSource)) - return; - - string? imagePath = null; - var isTempDownload = false; - - try - { - imagePath = await ResolveImagePathAsync(imageSource, checkpoint, ct); - if (imagePath == null) - { - lock (errors) - { - errors.Add($"Failed to resolve image source for shape {config.ShapeId}"); - } - - return; - } - - isTempDownload = IsTemporaryDownload(imageSource, imagePath); - - var picture = Presentation.GetPictureById(slidePart, config.ShapeId); - var shape = Presentation.GetShapeById(slidePart, config.ShapeId); - - if (shape == null && picture == null) - return; - - var targetSize = picture != null - ? ImageReplacer.GetPictureSize(picture) - : ImageReplacer.GetShapeSize(shape!); - - var bytes = await imageService.CropImageAsync(imagePath, targetSize, config.RoiType, config.CropType); - - lock (slideLock) - { - using var stream = new MemoryStream(bytes, false); - if (picture != null) - ImageReplacer.ReplaceImage(slidePart, picture, stream); - else if (shape != null) - ImageReplacer.ReplaceImage(slidePart, shape!, stream); - - successCount++; - details.Add(new ImageReplacementDetail(config.ShapeId, imageSource)); - } - } - catch (OperationCanceledException) - { - throw; - } - catch (CannotExtractUrlException ex) - { - Logger.LogWarning( - "The provided URL for shape {ShapeId} cannot be resolved: {Message} ({Url})", - config.ShapeId, ex.Message, ex.OriginalUrl); - } - catch (NotImageFileUrl ex) - { - Logger.LogWarning( - "The provided URL for shape {ShapeId} is not an image file: {Message} ({Url})", - config.ShapeId, ex.Message, ex.Url); - } - catch (Exception ex) - { - Logger.LogWarning(ex, - "Failed to process image for shape {ShapeId}, keeping placeholder", - config.ShapeId); - lock (errors) - { - errors.Add($"Shape {config.ShapeId}: {ex.Message}"); - } - } - finally - { - if (isTempDownload && !string.IsNullOrWhiteSpace(imagePath) && File.Exists(imagePath)) - try - { - File.Delete(imagePath); - } - catch (IOException) - { - // Ignore cleanup failures for temp downloads. - } - } - }); - - // Checkpoints inside parallel loop are tricky. We call them once at the end or begin, - // or accept that they will be called concurrently (Checkpoints must be thread-safe). - // Assuming JobCheckpoint delegate is thread-safe or we don't strictly need precise intermediate progress here for speed. - - return new ImageReplacementOutcome(successCount, errors.Count, errors, details); - } - - private async Task ResolveImagePathAsync( - string imageSource, - JobCheckpoint checkpoint, - CancellationToken cancellationToken) - { - if (File.Exists(imageSource)) - return imageSource; - - if (!UrlUtils.TryNormalizeHttpsUrl(imageSource, out var imageUri) || imageUri is null) - return null; - - await checkpoint(JobCheckpointStage.BeforeCloudResolve, cancellationToken); - var resolvedUri = imageUri; - if (CloudUrlResolver.IsCloudUrlSupported(imageUri)) - { - var client = httpClientFactory.CreateClient(); - resolvedUri = await CloudUrlResolver.ResolveLinkAsync(imageUri, client); - } - - await checkpoint(JobCheckpointStage.AfterCloudResolve, cancellationToken); - - await checkpoint(JobCheckpointStage.BeforeDownload, cancellationToken); - var result = await downloadClient.DownloadAsync(resolvedUri, - new DirectoryInfo(ConfigHolder.Value.Download.SaveFolder), cancellationToken); - await checkpoint(JobCheckpointStage.AfterDownload, cancellationToken); - - return result.Success ? result.FilePath : null; - } - - private static bool IsTemporaryDownload(string imageSource, string imagePath) - { - return !string.Equals(imageSource, imagePath, StringComparison.OrdinalIgnoreCase) - && !File.Exists(imageSource); - } - - private static string? GetImageSourceFromRowData(RowContent rowData, string[] columns) - { - foreach (var column in columns) - if (rowData.TryGetValue(column, out var value) && !string.IsNullOrWhiteSpace(value)) - return value; - return null; - } - - private static ReplaceInstructions BuildReplacementIndex(ReplaceInstructions replacements) - { - var index = new ReplaceInstructions(StringComparer.Ordinal); - foreach (var (key, value) in replacements) - { - var normalized = NormalizePlaceholder(key); - index.TryAdd(normalized, value); - } - - return index; - } - - private static string NormalizePlaceholder(string key) - { - var trimmed = key.Trim(); - if (trimmed.StartsWith("{{", StringComparison.Ordinal) - && trimmed.EndsWith("}}", StringComparison.Ordinal) - && trimmed.Length > 4) - return trimmed[2..^2].Trim(); - return trimmed; - } - - private sealed record TextReplacementOutcome(int Count, List Details); - - private sealed record ImageReplacementOutcome( - int Count, - int ErrorCount, - List Errors, - List Details); -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Infrastructure/Features/Slides/Services/SlideTemplateManager.cs b/backend/src/SlideGenerator.Infrastructure/Features/Slides/Services/SlideTemplateManager.cs deleted file mode 100644 index 4094a4f2..00000000 --- a/backend/src/SlideGenerator.Infrastructure/Features/Slides/Services/SlideTemplateManager.cs +++ /dev/null @@ -1,59 +0,0 @@ -using System.Collections.Concurrent; -using Microsoft.Extensions.Logging; -using SlideGenerator.Application.Features.Slides; -using SlideGenerator.Domain.Features.Slides; -using SlideGenerator.Infrastructure.Common.Base; -using SlideGenerator.Infrastructure.Features.Slides.Adapters; -using SlideGenerator.Infrastructure.Features.Slides.Exceptions; -using CoreTemplatePresentation = SlideGenerator.Framework.Slide.Models.TemplatePresentation; - -namespace SlideGenerator.Infrastructure.Features.Slides.Services; - -/// -/// Template presentation service implementation. -/// -public class SlideTemplateManager(ILogger logger) : Service(logger), ISlideTemplateManager -{ - private readonly ConcurrentDictionary _storage = new(); - - public bool AddTemplate(string filepath) - { - filepath = Path.GetFullPath(filepath); - - var isAdded = false; - _storage.GetOrAdd(filepath, path => - { - isAdded = true; - return new CoreTemplatePresentation(path); - }); - - if (isAdded) - Logger.LogInformation("Added template presentation: {FilePath}", filepath); - - return isAdded; - } - - public bool RemoveTemplate(string filepath) - { - filepath = Path.GetFullPath(filepath); - - if (_storage.TryRemove(filepath, out var presentation)) - { - presentation.Dispose(); - - Logger.LogInformation("Removed template presentation: {FilePath}", filepath); - return true; - } - - return false; - } - - public ITemplatePresentation GetTemplate(string filepath) - { - filepath = Path.GetFullPath(filepath); - - return _storage.TryGetValue(filepath, out var presentation) - ? new TemplatePresentationAdapter(presentation) - : throw new PresentationNotOpened(filepath); - } -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Infrastructure/Features/Slides/Services/SlideWorkingManager.cs b/backend/src/SlideGenerator.Infrastructure/Features/Slides/Services/SlideWorkingManager.cs deleted file mode 100644 index ac36178a..00000000 --- a/backend/src/SlideGenerator.Infrastructure/Features/Slides/Services/SlideWorkingManager.cs +++ /dev/null @@ -1,78 +0,0 @@ -using System.Collections.Concurrent; -using DocumentFormat.OpenXml.Packaging; -using DocumentFormat.OpenXml.Presentation; -using Microsoft.Extensions.Logging; -using SlideGenerator.Application.Features.Slides; -using SlideGenerator.Domain.Features.Slides; -using SlideGenerator.Infrastructure.Common.Base; -using SlideGenerator.Infrastructure.Features.Slides.Adapters; -using SlideGenerator.Infrastructure.Features.Slides.Exceptions; -using CoreWorkingPresentation = SlideGenerator.Framework.Slide.Models.WorkingPresentation; - -namespace SlideGenerator.Infrastructure.Features.Slides.Services; - -/// -/// Working presentation service implementation. -/// -public class SlideWorkingManager(ILogger logger) : Service(logger), ISlideWorkingManager -{ - private readonly ConcurrentDictionary _storage = new(); - - public bool GetOrAddWorkingPresentation(string filepath) - { - filepath = Path.GetFullPath(filepath); - var isAdded = false; - _storage.GetOrAdd(filepath, path => - { - isAdded = true; - return new CoreWorkingPresentation(path); - }); - - if (isAdded) - Logger.LogInformation("Added working presentation: {FilePath}", filepath); - return isAdded; - } - - public bool RemoveWorkingPresentation(string filepath) - { - filepath = Path.GetFullPath(filepath); - - if (_storage.TryRemove(filepath, out var presentation)) - { - presentation.Dispose(); - - Logger.LogInformation("Removed working presentation: {FilePath}", filepath); - return true; - } - - return false; - } - - public IWorkingPresentation GetWorkingPresentation(string filepath) - { - filepath = Path.GetFullPath(filepath); - - return _storage.TryGetValue(filepath, out var presentation) - ? new WorkingPresentationAdapter(presentation) - : throw new PresentationNotOpened(filepath); - } - - internal SlidePart CopyFirstSlideToLast(string filepath) - { - filepath = Path.GetFullPath(filepath); - - if (!_storage.TryGetValue(filepath, out var presentation)) - throw new PresentationNotOpened(filepath); - - var slideIdList = presentation.GetSlideIdList(); - var firstSlideId = slideIdList?.ChildElements.OfType().First(); - var slideRId = firstSlideId?.RelationshipId?.Value - ?? throw new InvalidOperationException("No slide relationship ID found"); - - var newPosition = presentation.SlideCount + 1; - var newSlide = presentation.CopySlide(slideRId, newPosition); - - Logger.LogDebug("Copied first slide to position {Position} in {FilePath}", newPosition, filepath); - return newSlide; - } -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Ipc/Contracts/Requests/JobIdRequest.cs b/backend/src/SlideGenerator.Ipc/Contracts/Requests/JobIdRequest.cs new file mode 100644 index 00000000..f7916441 --- /dev/null +++ b/backend/src/SlideGenerator.Ipc/Contracts/Requests/JobIdRequest.cs @@ -0,0 +1,3 @@ +namespace SlideGenerator.Ipc.Contracts.Requests; + +public sealed record JobIdRequest(Guid JobId); \ No newline at end of file diff --git a/backend/src/SlideGenerator.Ipc/Contracts/Requests/ScanFileRequest.cs b/backend/src/SlideGenerator.Ipc/Contracts/Requests/ScanFileRequest.cs new file mode 100644 index 00000000..b38aa13c --- /dev/null +++ b/backend/src/SlideGenerator.Ipc/Contracts/Requests/ScanFileRequest.cs @@ -0,0 +1,3 @@ +namespace SlideGenerator.Ipc.Contracts.Requests; + +public sealed record ScanFileRequest(string FilePath); \ No newline at end of file diff --git a/backend/src/SlideGenerator.Presentation/Dockerfile b/backend/src/SlideGenerator.Ipc/Dockerfile similarity index 63% rename from backend/src/SlideGenerator.Presentation/Dockerfile rename to backend/src/SlideGenerator.Ipc/Dockerfile index 9421c543..1ec80159 100644 --- a/backend/src/SlideGenerator.Presentation/Dockerfile +++ b/backend/src/SlideGenerator.Ipc/Dockerfile @@ -11,19 +11,19 @@ EXPOSE 8081 FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build ARG BUILD_CONFIGURATION=Release WORKDIR /src -COPY ["SlideGenerator.Presentation/SlideGenerator.Presentation.csproj", "SlideGenerator.Presentation/"] -RUN dotnet restore "SlideGenerator.Presentation/SlideGenerator.Presentation.csproj" +COPY ["SlideGenerator.Ipc/SlideGenerator.Ipc.csproj", "SlideGenerator.Ipc/"] +RUN dotnet restore "SlideGenerator.Ipc/SlideGenerator.Ipc.csproj" COPY . . -WORKDIR "/SlideGenerator.Presentation" -RUN dotnet build "./SlideGenerator.Presentation.csproj" -c $BUILD_CONFIGURATION -o /app/build +WORKDIR "/SlideGenerator.Ipc" +RUN dotnet build "./SlideGenerator.Ipc.csproj" -c $BUILD_CONFIGURATION -o /app/build # This stage is used to publish the service project to be copied to the final stage FROM build AS publish ARG BUILD_CONFIGURATION=Release -RUN dotnet publish "./SlideGenerator.Presentation.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false +RUN dotnet publish "./SlideGenerator.Ipc.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false # This stage is used in production or when running from VS in regular mode (Default when not using the Debug configuration) FROM base AS final WORKDIR /app COPY --from=publish /app/publish . -ENTRYPOINT ["dotnet", "SlideGenerator.Presentation.dll"] \ No newline at end of file +ENTRYPOINT ["dotnet", "SlideGenerator.Ipc.dll"] \ No newline at end of file diff --git a/backend/src/SlideGenerator.Ipc/Endpoints/RpcEndpoint.Configs.cs b/backend/src/SlideGenerator.Ipc/Endpoints/RpcEndpoint.Configs.cs new file mode 100644 index 00000000..fd7c099f --- /dev/null +++ b/backend/src/SlideGenerator.Ipc/Endpoints/RpcEndpoint.Configs.cs @@ -0,0 +1,41 @@ +using StreamJsonRpc; + +namespace SlideGenerator.Ipc.Endpoints; + +public sealed partial class RpcEndpoint +{ + [JsonRpcMethod("configs.get")] + public object GetConfigs() + { + return _configManager.Current; + } + + [JsonRpcMethod("configs.reload")] + public object ReloadConfigs() + { + var ok = _configManager.Load(); + return new + { + ok, + config = _configManager.Current + }; + } + + [JsonRpcMethod("configs.save")] + public object SaveConfigs() + { + var ok = _configManager.Save(); + return new { ok }; + } + + [JsonRpcMethod("configs.reset")] + public object ResetConfigs() + { + var ok = _configManager.ResetToDefaults(); + return new + { + ok, + config = _configManager.Current + }; + } +} diff --git a/backend/src/SlideGenerator.Ipc/Endpoints/RpcEndpoint.Excel.cs b/backend/src/SlideGenerator.Ipc/Endpoints/RpcEndpoint.Excel.cs new file mode 100644 index 00000000..9516ecf7 --- /dev/null +++ b/backend/src/SlideGenerator.Ipc/Endpoints/RpcEndpoint.Excel.cs @@ -0,0 +1,17 @@ +using SlideGenerator.Ipc.Contracts.Requests; +using SlideGenerator.Scanning.Models.Sheets; +using StreamJsonRpc; + +namespace SlideGenerator.Ipc.Endpoints; + +public sealed partial class RpcEndpoint +{ + [JsonRpcMethod("sheet.scan")] + public async Task ScanSheetAsync(ScanFileRequest request, CancellationToken cancellationToken) + { + if (request == null || string.IsNullOrWhiteSpace(request.FilePath)) + throw new ArgumentException("params.filePath is required", nameof(request)); + + return await _backendService.ScanSheetAsync(request.FilePath, cancellationToken); + } +} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Ipc/Endpoints/RpcEndpoint.Jobs.cs b/backend/src/SlideGenerator.Ipc/Endpoints/RpcEndpoint.Jobs.cs new file mode 100644 index 00000000..491b156d --- /dev/null +++ b/backend/src/SlideGenerator.Ipc/Endpoints/RpcEndpoint.Jobs.cs @@ -0,0 +1,64 @@ +using SlideGenerator.Generating.Models; +using SlideGenerator.Ipc.Contracts.Requests; +using SlideGenerator.Jobs.Entities.Jobs; +using StreamJsonRpc; + +namespace SlideGenerator.Ipc.Endpoints; + +public sealed partial class RpcEndpoint +{ + [JsonRpcMethod("jobs.create")] + public async Task CreateJobAsync(GenerateSlidesRequest request, CancellationToken cancellationToken) + { + if (request == null) + throw new ArgumentException("jobs.create params is invalid", nameof(request)); + + var jobId = await _backendService.CreateJobAsync(request, cancellationToken); + return new { jobId }; + } + + [JsonRpcMethod("jobs.get")] + public async Task GetJobAsync(JobIdRequest request, CancellationToken cancellationToken) + { + if (request == null) + throw new ArgumentException("params.jobId is required", nameof(request)); + + return await _backendService.GetJobAsync(request.JobId, cancellationToken); + } + + [JsonRpcMethod("jobs.list")] + public async Task> ListJobsAsync(CancellationToken cancellationToken) + { + return await _backendService.ListJobsAsync(cancellationToken); + } + + [JsonRpcMethod("jobs.pause")] + public async Task PauseJobAsync(JobIdRequest request, CancellationToken cancellationToken) + { + await ControlJobAsync(request, JobControlEntity.Pause, cancellationToken); + return new { ok = true }; + } + + [JsonRpcMethod("jobs.resume")] + public async Task ResumeJobAsync(JobIdRequest request, CancellationToken cancellationToken) + { + await ControlJobAsync(request, JobControlEntity.Resume, cancellationToken); + return new { ok = true }; + } + + [JsonRpcMethod("jobs.cancel")] + public async Task CancelJobAsync(JobIdRequest request, CancellationToken cancellationToken) + { + await ControlJobAsync(request, JobControlEntity.Cancel, cancellationToken); + return new { ok = true }; + } + + private async Task ControlJobAsync(JobIdRequest request, JobControlEntity action, + CancellationToken cancellationToken) + { + if (request == null) + throw new ArgumentException("params.jobId is required", nameof(request)); + + await _backendService.ControlJobAsync(request.JobId, action, cancellationToken); + } +} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Ipc/Endpoints/RpcEndpoint.Slides.cs b/backend/src/SlideGenerator.Ipc/Endpoints/RpcEndpoint.Slides.cs new file mode 100644 index 00000000..1cb56cad --- /dev/null +++ b/backend/src/SlideGenerator.Ipc/Endpoints/RpcEndpoint.Slides.cs @@ -0,0 +1,18 @@ +using SlideGenerator.Ipc.Contracts.Requests; +using SlideGenerator.Scanning.Models.Slides; +using StreamJsonRpc; + +namespace SlideGenerator.Ipc.Endpoints; + +public sealed partial class RpcEndpoint +{ + [JsonRpcMethod("slide.scan")] + public async Task ScanSlidesAsync(ScanFileRequest request, + CancellationToken cancellationToken) + { + if (request == null || string.IsNullOrWhiteSpace(request.FilePath)) + throw new ArgumentException("params.filePath is required", nameof(request)); + + return await _backendService.ScanSlideAsync(request.FilePath, cancellationToken); + } +} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Ipc/Endpoints/RpcEndpoint.System.cs b/backend/src/SlideGenerator.Ipc/Endpoints/RpcEndpoint.System.cs new file mode 100644 index 00000000..77ea0e2d --- /dev/null +++ b/backend/src/SlideGenerator.Ipc/Endpoints/RpcEndpoint.System.cs @@ -0,0 +1,12 @@ +using StreamJsonRpc; + +namespace SlideGenerator.Ipc.Endpoints; + +public sealed partial class RpcEndpoint +{ + [JsonRpcMethod("system.health")] + public static object Health() + { + return new { ok = true }; + } +} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Ipc/Endpoints/RpcEndpoint.cs b/backend/src/SlideGenerator.Ipc/Endpoints/RpcEndpoint.cs new file mode 100644 index 00000000..fb25ce57 --- /dev/null +++ b/backend/src/SlideGenerator.Ipc/Endpoints/RpcEndpoint.cs @@ -0,0 +1,43 @@ +using SlideGenerator.Jobs; +using SlideGenerator.Jobs.Entities.Jobs; +using SlideGenerator.Configs.Services; +using JsonRpcConnection = StreamJsonRpc.JsonRpc; + +namespace SlideGenerator.Ipc.Endpoints; + +public sealed partial class RpcEndpoint : IDisposable +{ + private readonly BackendService _backendService; + private readonly ConfigManager _configManager; + private JsonRpcConnection? _rpc; + + public RpcEndpoint(BackendService backendService, ConfigManager configManager) + { + _backendService = backendService; + _configManager = configManager; + _backendService.JobUpdated += HandleJobUpdated; + } + + public void Dispose() + { + _backendService.JobUpdated -= HandleJobUpdated; + } + + internal void Attach(JsonRpcConnection rpc) + { + _rpc = rpc; + } + + private void HandleJobUpdated(JobSnapshotEntity snapshot) + { + var rpc = _rpc; + if (rpc == null) return; + + _ = rpc.NotifyAsync("jobs.updated", snapshot) + .ContinueWith(task => + { + if (task.Exception != null) + Console.Error.WriteLine(task.Exception); + }, TaskScheduler.Default); + } +} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Ipc/Program.cs b/backend/src/SlideGenerator.Ipc/Program.cs new file mode 100644 index 00000000..35812576 --- /dev/null +++ b/backend/src/SlideGenerator.Ipc/Program.cs @@ -0,0 +1,66 @@ +using Microsoft.Extensions.DependencyInjection; +using Elsa.Extensions; +using Elsa.EntityFrameworkCore.Extensions; +using Elsa.EntityFrameworkCore.Modules.Management; +using Elsa.EntityFrameworkCore.Modules.Runtime; +using StreamJsonRpc; +using SlideGenerator.Configs.Contracts; +using SlideGenerator.Configs.Entities; +using SlideGenerator.Configs.Services; +using SlideGenerator.Generating.Services; +using SlideGenerator.Ipc.Endpoints; +using SlideGenerator.Jobs; + +namespace SlideGenerator.Ipc; + +public static class Program +{ + public static async Task Main() + { + var services = ConfigureServices(); + + await using var serviceProvider = services.BuildServiceProvider(); + var endpoint = serviceProvider.GetRequiredService(); + await using var sendingStream = Console.OpenStandardOutput(); + await using var receivingStream = Console.OpenStandardInput(); + + var rpc = JsonRpc.Attach(sendingStream, receivingStream, endpoint); + endpoint.Attach(rpc); + rpc.Disconnected += (_, disconnectedArgs) => + { + if (disconnectedArgs.Exception != null) + Console.Error.WriteLine(disconnectedArgs.Exception); + }; + + await rpc.Completion; + } + + private static ServiceCollection ConfigureServices() + { + var services = new ServiceCollection(); + var jobsDbConnection = $"Data Source={Config.DatabasePath}"; + services.AddElsa(elsa => + { + elsa.UseWorkflowManagement(management => + management.UseEntityFrameworkCore(ef => ef.UseSqlite(jobsDbConnection))); + elsa.UseWorkflowRuntime(runtime => + runtime.UseEntityFrameworkCore(ef => ef.UseSqlite(jobsDbConnection))); + }); + services.AddSingleton(_ => + { + var configManager = new ConfigManager(); + configManager.Load(); + return configManager; + }); + services.AddSingleton(serviceProvider => + serviceProvider.GetRequiredService()); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + return services; + } +} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Ipc/Properties/launchSettings.json b/backend/src/SlideGenerator.Ipc/Properties/launchSettings.json new file mode 100644 index 00000000..f6b35971 --- /dev/null +++ b/backend/src/SlideGenerator.Ipc/Properties/launchSettings.json @@ -0,0 +1,20 @@ +{ + "profiles": { + "Container (Dockerfile)": { + "commandName": "Docker", + "launchBrowser": true, + "launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}", + "environmentVariables": { + "ASPNETCORE_HTTPS_PORTS": "8081", + "ASPNETCORE_HTTP_PORTS": "8080" + }, + "publishAllPorts": true, + "useSSL": true, + "containerName": "SlideGeneratorBackend" + }, + "JsonRpc": { + "commandName": "Project" + } + }, + "$schema": "https://json.schemastore.org/launchsettings.json" +} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Ipc/SlideGenerator.Ipc.csproj b/backend/src/SlideGenerator.Ipc/SlideGenerator.Ipc.csproj new file mode 100644 index 00000000..0d2419f7 --- /dev/null +++ b/backend/src/SlideGenerator.Ipc/SlideGenerator.Ipc.csproj @@ -0,0 +1,34 @@ + + + + net10.0 + enable + enable + Exe + true + GPL-3.0-only + $(NoWarn);1591 + false + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/backend/src/SlideGenerator.Presentation/appsettings.Development.json b/backend/src/SlideGenerator.Ipc/appsettings.Development.json similarity index 100% rename from backend/src/SlideGenerator.Presentation/appsettings.Development.json rename to backend/src/SlideGenerator.Ipc/appsettings.Development.json diff --git a/backend/src/SlideGenerator.Presentation/appsettings.json b/backend/src/SlideGenerator.Ipc/appsettings.json similarity index 100% rename from backend/src/SlideGenerator.Presentation/appsettings.json rename to backend/src/SlideGenerator.Ipc/appsettings.json diff --git a/backend/src/SlideGenerator.Jobs/BackendService.cs b/backend/src/SlideGenerator.Jobs/BackendService.cs new file mode 100644 index 00000000..094cad16 --- /dev/null +++ b/backend/src/SlideGenerator.Jobs/BackendService.cs @@ -0,0 +1,746 @@ +using System.Collections.Concurrent; +using System.Globalization; +using System.Text.Json; +using System.Threading.Channels; +using ClosedXML.Excel; +using Microsoft.Data.Sqlite; +using SlideGenerator.Configs.Contracts; +using SlideGenerator.Configs.Entities; +using SlideGenerator.Framework.Sheet.Services; +using SlideGenerator.Framework.Slide.Services; +using SlideGenerator.Generating.Models; +using SlideGenerator.Generating.Services; +using SlideGenerator.Jobs.Entities.Jobs; +using SlideGenerator.Scanning.Models.Sheets; +using SlideGenerator.Scanning.Models.Slides; +using SlideGenerator.Scanning.Services; + +#pragma warning disable CS8602 + +namespace SlideGenerator.Jobs; + +/// +/// Orchestrates job lifecycle, queue processing, and persistence for slide generation jobs. +/// +public sealed class BackendService : IAsyncDisposable +{ + /// + /// Maximum number of queued jobs buffered in memory. + /// + private const int QueueCapacity = 128; + + /// + /// Limits concurrent workbook-level executions. + /// + private readonly SemaphoreSlim _bookSemaphore; + + /// + /// Runtime control flags for jobs (pause, resume, cancel). + /// + private readonly ConcurrentDictionary _controlStates = new(); + + /// + /// SQLite database file path for job persistence. + /// + private readonly string _dbPath; + + /// + /// Runtime generator for per-row slide operations. + /// + private readonly GenerateService _generateService; + + /// + /// Elsa workflow dispatcher for snapshot persistence. + /// + private readonly JobSnapshotWorkflowDispatcher _snapshotWorkflowDispatcher; + + /// + /// Channel used to queue job identifiers for processing. + /// + private readonly Channel _queue; + + /// + /// Limits concurrent worksheet executions across active jobs. + /// + private readonly SemaphoreSlim _sheetSemaphore; + + /// + /// Cancellation source for the background queue worker. + /// + private readonly CancellationTokenSource _workerCts = new(); + + /// + /// Background task that consumes and dispatches queued jobs. + /// + private readonly Task _workerTask; + + /// + /// Initializes queue workers, configuration, persistence, and startup job recovery. + /// + public BackendService( + IConfigProvider configProvider, + GenerateService generateService, + JobSnapshotWorkflowDispatcher snapshotWorkflowDispatcher) + { + var config = configProvider.Current; + _generateService = generateService; + _snapshotWorkflowDispatcher = snapshotWorkflowDispatcher; + + _dbPath = Config.DatabasePath; + Directory.CreateDirectory(Path.GetDirectoryName(_dbPath) ?? AppContext.BaseDirectory); + InitializeDatabase(); + + var maxBooks = Math.Max(1, config.Job.MaxConcurrentJobs); + _bookSemaphore = new SemaphoreSlim(maxBooks, maxBooks); + _sheetSemaphore = new SemaphoreSlim(Math.Max(1, Environment.ProcessorCount / 2)); + _queue = Channel.CreateBounded(new BoundedChannelOptions(QueueCapacity) + { + SingleReader = true, + SingleWriter = false, + FullMode = BoundedChannelFullMode.Wait + }); + + ResumeJobsOnStartupAsync(_workerCts.Token).GetAwaiter().GetResult(); + _workerTask = Task.Run(() => ProcessQueueAsync(_workerCts.Token)); + } + + /// + /// Stops worker processing and disposes allocated resources. + /// + public async ValueTask DisposeAsync() + { + _queue.Writer.TryComplete(); + await _workerCts.CancelAsync(); + try + { + await _workerTask; + } + catch (OperationCanceledException) + { + } + + await _generateService.DisposeAsync(); + _sheetSemaphore.Dispose(); + _bookSemaphore.Dispose(); + _workerCts.Dispose(); + } + + /// + /// Raised whenever a job snapshot is updated. + /// + public event Action? JobUpdated; + + /// + /// Scans a slide file and returns detected placeholders and image shape ids. + /// + public Task ScanSlideAsync(string filePath, CancellationToken cancellationToken) + { + return ScanService.ScanPresentationAsync(filePath, cancellationToken); + } + + /// + /// Scans an excel file and returns worksheet header/record metadata. + /// + public Task ScanSheetAsync(string filePath, CancellationToken cancellationToken) + { + return ScanService.ScanWorkbookAsync(filePath, cancellationToken); + } + + /// + /// Creates and enqueues a generation job. + /// + public async Task CreateJobAsync(GenerateSlidesRequest request, CancellationToken cancellationToken) + { + ValidationService.ValidateRequest(request); + + var jobId = Guid.NewGuid(); + var now = DateTimeOffset.UtcNow; + var requestJson = JsonSerializer.Serialize(request); + + await using var connection = OpenConnection(); + await connection.OpenAsync(cancellationToken); + + var insertJob = connection.CreateCommand(); + insertJob.CommandText = @" +INSERT INTO jobs(id, status, progress, created_at, updated_at, request_json, message) +VALUES($id, $status, $progress, $created, $updated, $request, $message);"; + insertJob.Parameters.AddWithValue("$id", jobId.ToString()); + insertJob.Parameters.AddWithValue("$status", nameof(JobStatusEntity.Pending)); + insertJob.Parameters.AddWithValue("$progress", 0d); + insertJob.Parameters.AddWithValue("$created", now.ToString("O", CultureInfo.InvariantCulture)); + insertJob.Parameters.AddWithValue("$updated", now.ToString("O", CultureInfo.InvariantCulture)); + insertJob.Parameters.AddWithValue("$request", requestJson); + insertJob.Parameters.AddWithValue("$message", "Queued"); + await insertJob.ExecuteNonQueryAsync(cancellationToken); + + foreach (var sheetName in ValidationService.ResolveSelectedSheets(request)) + { + var outputPath = Path.Combine(request.SaveFolder, $"{EscapeFileName(sheetName)}.pptx"); + var insertSheet = connection.CreateCommand(); + insertSheet.CommandText = @" +INSERT INTO job_sheets(job_id, sheet_name, output_path, current_row, total_rows, status, error) +VALUES($jobId, $sheetName, $outputPath, 0, 0, $status, NULL);"; + insertSheet.Parameters.AddWithValue("$jobId", jobId.ToString()); + insertSheet.Parameters.AddWithValue("$sheetName", sheetName); + insertSheet.Parameters.AddWithValue("$outputPath", outputPath); + insertSheet.Parameters.AddWithValue("$status", nameof(JobStatusEntity.Pending)); + await insertSheet.ExecuteNonQueryAsync(cancellationToken); + } + + _controlStates[jobId] = new JobControlState(); + await _queue.Writer.WriteAsync(jobId, cancellationToken); + + var snapshot = await GetJobAsync(jobId, cancellationToken); + if (snapshot != null) RaiseJobUpdated(snapshot); + return jobId; + } + + /// + /// Retrieves a persisted job snapshot by identifier. + /// + public async Task GetJobAsync(Guid jobId, CancellationToken cancellationToken) + { + await using var connection = OpenConnection(); + await connection.OpenAsync(cancellationToken); + + var jobCommand = connection.CreateCommand(); + jobCommand.CommandText = @" +SELECT status, progress, created_at, updated_at, message +FROM jobs +WHERE id = $id;"; + jobCommand.Parameters.AddWithValue("$id", jobId.ToString()); + + await using var reader = await jobCommand.ExecuteReaderAsync(cancellationToken); + if (!await reader.ReadAsync(cancellationToken)) return null; + + var status = Enum.Parse(reader.GetString(0), true); + var progress = reader.GetDouble(1); + var createdAt = DateTimeOffset.Parse(reader.GetString(2), CultureInfo.InvariantCulture); + var updatedAt = DateTimeOffset.Parse(reader.GetString(3), CultureInfo.InvariantCulture); + var message = reader.IsDBNull(4) ? null : reader.GetString(4); + + var sheets = new List(); + var sheetCommand = connection.CreateCommand(); + sheetCommand.CommandText = @" +SELECT sheet_name, output_path, current_row, total_rows, status, error +FROM job_sheets +WHERE job_id = $jobId +ORDER BY sheet_name;"; + sheetCommand.Parameters.AddWithValue("$jobId", jobId.ToString()); + + await using var sheetReader = await sheetCommand.ExecuteReaderAsync(cancellationToken); + while (await sheetReader.ReadAsync(cancellationToken)) + sheets.Add(new SheetCheckpointEntity( + sheetReader.GetString(0), + sheetReader.GetString(1), + sheetReader.GetInt32(2), + sheetReader.GetInt32(3), + Enum.Parse(sheetReader.GetString(4), true), + sheetReader.IsDBNull(5) ? null : sheetReader.GetString(5))); + + return new JobSnapshotEntity(jobId, status, progress, createdAt, updatedAt, message, sheets); + } + + /// + /// Lists all persisted jobs ordered by creation time descending. + /// + public async Task> ListJobsAsync(CancellationToken cancellationToken) + { + var ids = new List(); + await using var connection = OpenConnection(); + await connection.OpenAsync(cancellationToken); + + var command = connection.CreateCommand(); + command.CommandText = "SELECT id FROM jobs ORDER BY created_at DESC;"; + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + ids.Add(Guid.Parse(reader.GetString(0))); + + var snapshots = new List(ids.Count); + foreach (var id in ids) + { + var snapshot = await GetJobAsync(id, cancellationToken); + if (snapshot != null) snapshots.Add(snapshot); + } + + return snapshots; + } + + /// + /// Applies a control action to a job. + /// + public async Task ControlJobAsync(Guid jobId, JobControlEntity action, CancellationToken cancellationToken) + { + var state = _controlStates.GetOrAdd(jobId, _ => new JobControlState()); + switch (action) + { + case JobControlEntity.Pause: + state.IsPaused = true; + await UpdateJobStatusAsync(jobId, JobStatusEntity.Paused, "Paused", cancellationToken); + break; + case JobControlEntity.Resume: + state.IsPaused = false; + await UpdateJobStatusAsync(jobId, JobStatusEntity.Pending, "Queued for resume", cancellationToken); + await _queue.Writer.WriteAsync(jobId, cancellationToken); + break; + case JobControlEntity.Cancel: + state.IsCancelled = true; + await UpdateJobStatusAsync(jobId, JobStatusEntity.Cancelled, "Cancelled", cancellationToken); + break; + default: + throw new ArgumentOutOfRangeException(nameof(action), action, null); + } + + var snapshot = await GetJobAsync(jobId, cancellationToken); + if (snapshot != null) RaiseJobUpdated(snapshot); + } + + /// + /// Continuously consumes queued jobs and dispatches book-level execution. + /// + private async Task ProcessQueueAsync(CancellationToken cancellationToken) + { + await foreach (var jobId in _queue.Reader.ReadAllAsync(cancellationToken)) + { + var snapshot = await GetJobAsync(jobId, cancellationToken); + if (snapshot == null) continue; + if (snapshot.Status is JobStatusEntity.Completed or JobStatusEntity.Cancelled) continue; + + var state = _controlStates.GetOrAdd(jobId, _ => new JobControlState()); + if (state.IsCancelled) continue; + + await _bookSemaphore.WaitAsync(cancellationToken); + _ = Task.Run(async () => + { + try + { + await RunBookJobAsync(jobId, state, cancellationToken); + } + finally + { + _bookSemaphore.Release(); + } + }, cancellationToken); + } + } + + /// + /// Runs one workbook generation job across selected sheets. + /// + private async Task RunBookJobAsync(Guid jobId, JobControlState state, CancellationToken cancellationToken) + { + var request = await LoadRequestAsync(jobId, cancellationToken); + if (request == null) return; + + try + { + await UpdateJobStatusAsync(jobId, JobStatusEntity.Running, "Running", cancellationToken); + + Directory.CreateDirectory(request.SaveFolder); + using var workbook = WorkbookService.OpenWorkbook(request.Sheet.FilePath); + + var selectedSheets = ValidationService.ResolveSelectedSheets(request); + var sheetTasks = selectedSheets.Select(async sheetName => + { + await _sheetSemaphore.WaitAsync(cancellationToken); + try + { + await RunSheetJobAsync(jobId, request, workbook, sheetName, state, cancellationToken); + } + finally + { + _sheetSemaphore.Release(); + } + }).ToList(); + + await Task.WhenAll(sheetTasks); + + if (state.IsCancelled) + await UpdateJobStatusAsync(jobId, JobStatusEntity.Cancelled, "Cancelled", cancellationToken); + else + await UpdateJobStatusAsync(jobId, JobStatusEntity.Completed, "Completed", cancellationToken); + } + catch (Exception ex) + { + await UpdateJobStatusAsync(jobId, JobStatusEntity.Failed, ex.Message, cancellationToken); + } + + var snapshot = await GetJobAsync(jobId, cancellationToken); + if (snapshot != null) RaiseJobUpdated(snapshot); + } + + /// + /// Processes one selected worksheet into an output presentation. + /// + private async Task RunSheetJobAsync( + Guid jobId, + GenerateSlidesRequest request, + IXLWorkbook workbook, + string sheetName, + JobControlState control, + CancellationToken cancellationToken) + { + var worksheet = + workbook.Worksheets.FirstOrDefault(ws => + string.Equals(ws.Name, sheetName, StringComparison.OrdinalIgnoreCase)); + if (worksheet == null) throw new InvalidOperationException($"Sheet '{sheetName}' not found."); + + var used = WorksheetService.GetContentRange(worksheet); + var totalRows = Math.Max(0, (used?.RowCount() ?? 1) - 1); + + var template = request.TemplateMap.TryGetValue(sheetName, out var sheetTemplate) + ? sheetTemplate + : throw new InvalidOperationException( + $"Template configuration is missing for sheet '{sheetName}'."); + + var outputPath = Path.Combine(request.SaveFolder, $"{EscapeFileName(sheetName)}.pptx"); + File.Copy(template.FilePath, outputPath, true); + + await UpdateSheetStateAsync(jobId, sheetName, outputPath, 0, totalRows, JobStatusEntity.Running, null, + cancellationToken); + await EnsureRowStatesInitializedAsync(jobId, sheetName, totalRows, cancellationToken); + + using var document = XmlPresentationService.OpenOrCreatePresentation(outputPath, true); + var presentationPart = document.PresentationPart + ?? throw new InvalidOperationException("Invalid presentation part."); + var slideId = XmlPresentationService.GetSlideId(document, template.Index - 1) + ?? throw new InvalidOperationException( + $"Template slide index {template.Index} is invalid."); + var relationshipId = slideId.RelationshipId?.Value + ?? throw new InvalidOperationException("Template slide relationship id is missing."); + + for (var row = 1; row <= totalRows; row++) + { + cancellationToken.ThrowIfCancellationRequested(); + if (control.IsCancelled) throw new OperationCanceledException("Job cancelled"); + + var rowState = await GetRowStateAsync(jobId, sheetName, row, cancellationToken); + if (rowState == RowProcessState.Completed) + { + await UpdateSheetStateAsync(jobId, sheetName, outputPath, row, totalRows, JobStatusEntity.Running, + null, + cancellationToken); + continue; + } + + while (control.IsPaused && !control.IsCancelled) + { + await UpdateJobStatusAsync(jobId, JobStatusEntity.Paused, "Paused", cancellationToken); + await Task.Delay(250, cancellationToken); + } + + var rowKey = GetRowIdempotencyKey(jobId, sheetName, row); + await SetRowStateAsync(jobId, sheetName, row, RowProcessState.InProgress, rowKey, null, cancellationToken); + + await _generateService.ProcessRowAsync( + document, + relationshipId, + used, + row, + request.TextConfigs, + request.ImageConfigs, + cancellationToken); + + await SetRowStateAsync(jobId, sheetName, row, RowProcessState.Completed, rowKey, null, cancellationToken); + + await UpdateSheetStateAsync(jobId, sheetName, outputPath, row, totalRows, JobStatusEntity.Running, null, + cancellationToken); + + var progress = ComputeProgress(jobId); + await UpdateJobProgressAsync(jobId, progress, cancellationToken); + } + + XmlPresentationService.RemoveSlide(document, template.Index); + document.Save(); + + await UpdateSheetStateAsync(jobId, sheetName, outputPath, totalRows, totalRows, JobStatusEntity.Completed, + null, + cancellationToken); + } + + + private static string EscapeFileName(string value) + { + var invalid = Path.GetInvalidFileNameChars(); + return string.Concat(value.Select(ch => invalid.Contains(ch) ? '_' : ch)); + } + + private static string GetRowIdempotencyKey(Guid jobId, string sheetName, int row) + { + return $"{jobId:N}:{sheetName}:{row}"; + } + + private async Task LoadRequestAsync(Guid jobId, CancellationToken cancellationToken) + { + await using var connection = OpenConnection(); + await connection.OpenAsync(cancellationToken); + + var command = connection.CreateCommand(); + command.CommandText = "SELECT request_json FROM jobs WHERE id = $id;"; + command.Parameters.AddWithValue("$id", jobId.ToString()); + var json = await command.ExecuteScalarAsync(cancellationToken) as string; + return string.IsNullOrWhiteSpace(json) + ? null + : JsonSerializer.Deserialize(json); + } + + private async Task ResumeJobsOnStartupAsync(CancellationToken cancellationToken) + { + await using var connection = OpenConnection(); + await connection.OpenAsync(cancellationToken); + + var command = connection.CreateCommand(); + command.CommandText = @" +SELECT id, status +FROM jobs +WHERE status IN ('Pending', 'Running');"; + + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + var jobId = Guid.Parse(reader.GetString(0)); + _controlStates.TryAdd(jobId, new JobControlState()); + await _queue.Writer.WriteAsync(jobId, cancellationToken); + } + } + + private async Task UpdateJobStatusAsync(Guid jobId, JobStatusEntity status, string? message, + CancellationToken cancellationToken) + { + await using var connection = OpenConnection(); + await connection.OpenAsync(cancellationToken); + + var command = connection.CreateCommand(); + command.CommandText = @" +UPDATE jobs +SET status = $status, + updated_at = $updatedAt, + message = $message +WHERE id = $id;"; + command.Parameters.AddWithValue("$status", status.ToString()); + command.Parameters.AddWithValue("$updatedAt", + DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture)); + command.Parameters.AddWithValue("$message", message ?? string.Empty); + command.Parameters.AddWithValue("$id", jobId.ToString()); + await command.ExecuteNonQueryAsync(cancellationToken); + } + + private async Task UpdateJobProgressAsync(Guid jobId, double progress, CancellationToken cancellationToken) + { + await using var connection = OpenConnection(); + await connection.OpenAsync(cancellationToken); + + var command = connection.CreateCommand(); + command.CommandText = @" +UPDATE jobs +SET progress = $progress, + updated_at = $updatedAt +WHERE id = $id;"; + command.Parameters.AddWithValue("$progress", Math.Clamp(progress, 0d, 100d)); + command.Parameters.AddWithValue("$updatedAt", + DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture)); + command.Parameters.AddWithValue("$id", jobId.ToString()); + await command.ExecuteNonQueryAsync(cancellationToken); + + var snapshot = await GetJobAsync(jobId, cancellationToken); + if (snapshot != null) RaiseJobUpdated(snapshot); + } + + private async Task UpdateSheetStateAsync(Guid jobId, string sheetName, string outputPath, int currentRow, + int totalRows, + JobStatusEntity status, string? error, CancellationToken cancellationToken) + { + await using var connection = OpenConnection(); + await connection.OpenAsync(cancellationToken); + + var command = connection.CreateCommand(); + command.CommandText = @" +UPDATE job_sheets +SET output_path = $outputPath, + current_row = $currentRow, + total_rows = $totalRows, + status = $status, + error = $error +WHERE job_id = $jobId AND sheet_name = $sheetName;"; + command.Parameters.AddWithValue("$outputPath", outputPath); + command.Parameters.AddWithValue("$currentRow", currentRow); + command.Parameters.AddWithValue("$totalRows", totalRows); + command.Parameters.AddWithValue("$status", status.ToString()); + command.Parameters.AddWithValue("$error", (object?)error ?? DBNull.Value); + command.Parameters.AddWithValue("$jobId", jobId.ToString()); + command.Parameters.AddWithValue("$sheetName", sheetName); + await command.ExecuteNonQueryAsync(cancellationToken); + + var snapshot = await GetJobAsync(jobId, cancellationToken); + if (snapshot != null) RaiseJobUpdated(snapshot); + } + + private async Task EnsureRowStatesInitializedAsync(Guid jobId, string sheetName, int totalRows, + CancellationToken cancellationToken) + { + await using var connection = OpenConnection(); + await connection.OpenAsync(cancellationToken); + + var countCommand = connection.CreateCommand(); + countCommand.CommandText = @" +SELECT COUNT(1) +FROM job_rows +WHERE job_id = $jobId AND sheet_name = $sheetName;"; + countCommand.Parameters.AddWithValue("$jobId", jobId.ToString()); + countCommand.Parameters.AddWithValue("$sheetName", sheetName); + var count = Convert.ToInt32(await countCommand.ExecuteScalarAsync(cancellationToken), + CultureInfo.InvariantCulture); + if (count > 0) return; + + await using var transaction = (SqliteTransaction)await connection.BeginTransactionAsync(cancellationToken); + for (var row = 1; row <= totalRows; row++) + { + var insert = connection.CreateCommand(); + insert.Transaction = transaction; + insert.CommandText = @" +INSERT INTO job_rows(job_id, sheet_name, row_index, idempotency_key, status, updated_at, message) +VALUES($jobId, $sheetName, $rowIndex, $key, $status, $updatedAt, NULL);"; + insert.Parameters.AddWithValue("$jobId", jobId.ToString()); + insert.Parameters.AddWithValue("$sheetName", sheetName); + insert.Parameters.AddWithValue("$rowIndex", row); + insert.Parameters.AddWithValue("$key", GetRowIdempotencyKey(jobId, sheetName, row)); + insert.Parameters.AddWithValue("$status", nameof(RowProcessState.Pending)); + insert.Parameters.AddWithValue("$updatedAt", + DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture)); + await insert.ExecuteNonQueryAsync(cancellationToken); + } + + await transaction.CommitAsync(cancellationToken); + } + + private async Task GetRowStateAsync(Guid jobId, string sheetName, int row, + CancellationToken cancellationToken) + { + await using var connection = OpenConnection(); + await connection.OpenAsync(cancellationToken); + + var command = connection.CreateCommand(); + command.CommandText = @" +SELECT status +FROM job_rows +WHERE job_id = $jobId AND sheet_name = $sheetName AND row_index = $rowIndex;"; + command.Parameters.AddWithValue("$jobId", jobId.ToString()); + command.Parameters.AddWithValue("$sheetName", sheetName); + command.Parameters.AddWithValue("$rowIndex", row); + var value = await command.ExecuteScalarAsync(cancellationToken) as string; + + return Enum.TryParse(value, true, out var state) + ? state + : RowProcessState.Pending; + } + + private async Task SetRowStateAsync(Guid jobId, string sheetName, int row, RowProcessState state, + string idempotencyKey, string? message, CancellationToken cancellationToken) + { + await using var connection = OpenConnection(); + await connection.OpenAsync(cancellationToken); + + var command = connection.CreateCommand(); + command.CommandText = @" +UPDATE job_rows +SET status = $status, + idempotency_key = $key, + updated_at = $updatedAt, + message = $message +WHERE job_id = $jobId + AND sheet_name = $sheetName + AND row_index = $rowIndex;"; + command.Parameters.AddWithValue("$status", state.ToString()); + command.Parameters.AddWithValue("$key", idempotencyKey); + command.Parameters.AddWithValue("$updatedAt", + DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture)); + command.Parameters.AddWithValue("$message", (object?)message ?? DBNull.Value); + command.Parameters.AddWithValue("$jobId", jobId.ToString()); + command.Parameters.AddWithValue("$sheetName", sheetName); + command.Parameters.AddWithValue("$rowIndex", row); + await command.ExecuteNonQueryAsync(cancellationToken); + } + + private double ComputeProgress(Guid jobId) + { + using var connection = OpenConnection(); + connection.Open(); + + var command = connection.CreateCommand(); + command.CommandText = @" +SELECT COALESCE(SUM(current_row), 0), COALESCE(SUM(total_rows), 0) +FROM job_sheets +WHERE job_id = $jobId;"; + command.Parameters.AddWithValue("$jobId", jobId.ToString()); + using var reader = command.ExecuteReader(); + if (!reader.Read()) return 0d; + + var totalCurrent = reader.GetInt64(0); + var totalRows = reader.GetInt64(1); + if (totalRows <= 0) return 0d; + return totalCurrent * 100d / totalRows; + } + + private void InitializeDatabase() + { + using var connection = OpenConnection(); + connection.Open(); + + using var command = connection.CreateCommand(); + command.CommandText = @" +CREATE TABLE IF NOT EXISTS jobs( + id TEXT PRIMARY KEY, + status TEXT NOT NULL, + progress REAL NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + request_json TEXT NOT NULL, + message TEXT NULL +); + +CREATE TABLE IF NOT EXISTS job_sheets( + job_id TEXT NOT NULL, + sheet_name TEXT NOT NULL, + output_path TEXT NOT NULL, + current_row INTEGER NOT NULL, + total_rows INTEGER NOT NULL, + status TEXT NOT NULL, + error TEXT NULL, + PRIMARY KEY(job_id, sheet_name), + FOREIGN KEY(job_id) REFERENCES jobs(id) +); + +CREATE TABLE IF NOT EXISTS job_rows( + job_id TEXT NOT NULL, + sheet_name TEXT NOT NULL, + row_index INTEGER NOT NULL, + idempotency_key TEXT NOT NULL, + status TEXT NOT NULL, + updated_at TEXT NOT NULL, + message TEXT NULL, + PRIMARY KEY(job_id, sheet_name, row_index), + FOREIGN KEY(job_id, sheet_name) REFERENCES job_sheets(job_id, sheet_name) +);"; + command.ExecuteNonQuery(); + } + + private SqliteConnection OpenConnection() + { + return new SqliteConnection($"Data Source={_dbPath};Pooling=True;Cache=Shared"); + } + + private void RaiseJobUpdated(JobSnapshotEntity snapshot) + { + try + { + _ = _snapshotWorkflowDispatcher.PersistAsync(snapshot, CancellationToken.None); + JobUpdated?.Invoke(snapshot); + } + catch + { + // TODO: Log + } + } +} + +#pragma warning restore CS8602 \ No newline at end of file diff --git a/backend/src/SlideGenerator.Jobs/Entities/Jobs/JobControlEntity.cs b/backend/src/SlideGenerator.Jobs/Entities/Jobs/JobControlEntity.cs new file mode 100644 index 00000000..9f98c044 --- /dev/null +++ b/backend/src/SlideGenerator.Jobs/Entities/Jobs/JobControlEntity.cs @@ -0,0 +1,11 @@ +namespace SlideGenerator.Jobs.Entities.Jobs; + +/// +/// Represents supported control actions for a job. +/// +public enum JobControlEntity +{ + Pause, + Resume, + Cancel +} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Jobs/Entities/Jobs/JobSnapshotEntity.cs b/backend/src/SlideGenerator.Jobs/Entities/Jobs/JobSnapshotEntity.cs new file mode 100644 index 00000000..a923bfcb --- /dev/null +++ b/backend/src/SlideGenerator.Jobs/Entities/Jobs/JobSnapshotEntity.cs @@ -0,0 +1,20 @@ +namespace SlideGenerator.Jobs.Entities.Jobs; + +/// +/// Represents current snapshot of a job. +/// +/// Job identifier. +/// Current job status. +/// Current job progress percentage. +/// Job creation timestamp. +/// Last update timestamp. +/// Current summary message. +/// Per-sheet checkpoint collection. +public sealed record JobSnapshotEntity( + Guid JobId, + JobStatusEntity Status, + double Progress, + DateTimeOffset CreatedAt, + DateTimeOffset UpdatedAt, + string? Message, + IReadOnlyList Sheets); \ No newline at end of file diff --git a/backend/src/SlideGenerator.Jobs/Entities/Jobs/JobStatusEntity.cs b/backend/src/SlideGenerator.Jobs/Entities/Jobs/JobStatusEntity.cs new file mode 100644 index 00000000..e6709873 --- /dev/null +++ b/backend/src/SlideGenerator.Jobs/Entities/Jobs/JobStatusEntity.cs @@ -0,0 +1,14 @@ +namespace SlideGenerator.Jobs.Entities.Jobs; + +/// +/// Represents externally exposed job lifecycle statuses. +/// +public enum JobStatusEntity +{ + Pending, + Running, + Paused, + Completed, + Failed, + Cancelled +} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Jobs/Entities/Jobs/SheetCheckpointEntity.cs b/backend/src/SlideGenerator.Jobs/Entities/Jobs/SheetCheckpointEntity.cs new file mode 100644 index 00000000..c56b33a6 --- /dev/null +++ b/backend/src/SlideGenerator.Jobs/Entities/Jobs/SheetCheckpointEntity.cs @@ -0,0 +1,18 @@ +namespace SlideGenerator.Jobs.Entities.Jobs; + +/// +/// Represents checkpoint state of a sheet in a job. +/// +/// Sheet name. +/// Current output path for this sheet. +/// Current processed row index. +/// Total row count for this sheet. +/// Current sheet status. +/// Error message when failed, otherwise null. +public sealed record SheetCheckpointEntity( + string SheetName, + string OutputPath, + int CurrentRow, + int TotalRows, + JobStatusEntity Status, + string? Error); \ No newline at end of file diff --git a/backend/src/SlideGenerator.Jobs/JobControlState.cs b/backend/src/SlideGenerator.Jobs/JobControlState.cs new file mode 100644 index 00000000..69044472 --- /dev/null +++ b/backend/src/SlideGenerator.Jobs/JobControlState.cs @@ -0,0 +1,7 @@ +namespace SlideGenerator.Jobs; + +internal sealed class JobControlState +{ + public volatile bool IsCancelled; + public volatile bool IsPaused; +} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Jobs/JobSnapshotWorkflowDispatcher.cs b/backend/src/SlideGenerator.Jobs/JobSnapshotWorkflowDispatcher.cs new file mode 100644 index 00000000..d83c0d98 --- /dev/null +++ b/backend/src/SlideGenerator.Jobs/JobSnapshotWorkflowDispatcher.cs @@ -0,0 +1,44 @@ +using Elsa.Workflows; +using Elsa.Workflows.Options; +using Microsoft.Extensions.Logging; +using SlideGenerator.Jobs.Entities.Jobs; + +namespace SlideGenerator.Jobs; + +/// +/// Dispatches job snapshots through Elsa workflow runner for persistence. +/// +public sealed class JobSnapshotWorkflowDispatcher +{ + private readonly ILogger _logger; + private readonly IWorkflowRunner _workflowRunner; + + /// + /// Creates a snapshot workflow dispatcher. + /// + public JobSnapshotWorkflowDispatcher(IWorkflowRunner workflowRunner, ILogger logger) + { + _workflowRunner = workflowRunner; + _logger = logger; + } + + /// + /// Persists the specified snapshot through Elsa workflow runtime. + /// + public async Task PersistAsync(JobSnapshotEntity snapshot, CancellationToken cancellationToken) + { + var activity = new PersistJobSnapshotActivity + { + Snapshot = snapshot + }; + + try + { + await _workflowRunner.RunAsync(activity, new RunWorkflowOptions(), cancellationToken); + } + catch (Exception exception) + { + _logger.LogError(exception, "Failed to persist snapshot for job {JobId} via Elsa workflow", snapshot.JobId); + } + } +} diff --git a/backend/src/SlideGenerator.Jobs/PersistJobSnapshotActivity.cs b/backend/src/SlideGenerator.Jobs/PersistJobSnapshotActivity.cs new file mode 100644 index 00000000..82446340 --- /dev/null +++ b/backend/src/SlideGenerator.Jobs/PersistJobSnapshotActivity.cs @@ -0,0 +1,22 @@ +using Elsa.Workflows; +using SlideGenerator.Jobs.Entities.Jobs; + +namespace SlideGenerator.Jobs; + +/// +/// Elsa activity that persists a job snapshot. +/// +public sealed class PersistJobSnapshotActivity : Activity +{ + /// + /// Snapshot to persist. + /// + public required JobSnapshotEntity Snapshot { get; init; } + + /// + protected override async ValueTask ExecuteAsync(ActivityExecutionContext context) + { + _ = Snapshot; + await context.CompleteActivityAsync(); + } +} diff --git a/backend/src/SlideGenerator.Jobs/RowProcessState.cs b/backend/src/SlideGenerator.Jobs/RowProcessState.cs new file mode 100644 index 00000000..beb3884b --- /dev/null +++ b/backend/src/SlideGenerator.Jobs/RowProcessState.cs @@ -0,0 +1,9 @@ +namespace SlideGenerator.Jobs; + +internal enum RowProcessState +{ + Pending, + InProgress, + Completed, + Failed +} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Jobs/SlideGenerator.Jobs.csproj b/backend/src/SlideGenerator.Jobs/SlideGenerator.Jobs.csproj new file mode 100644 index 00000000..cd1069f1 --- /dev/null +++ b/backend/src/SlideGenerator.Jobs/SlideGenerator.Jobs.csproj @@ -0,0 +1,30 @@ + + + + net10.0 + enable + enable + true + $(NoWarn);1591 + win-x64 + GPL-3.0-only + false + + + + + + + + + + + + + + + + + + + diff --git a/backend/src/SlideGenerator.Presentation/Common/Exceptions/Hubs/ConnectionNotFound.cs b/backend/src/SlideGenerator.Presentation/Common/Exceptions/Hubs/ConnectionNotFound.cs deleted file mode 100644 index ae18eca7..00000000 --- a/backend/src/SlideGenerator.Presentation/Common/Exceptions/Hubs/ConnectionNotFound.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace SlideGenerator.Presentation.Common.Exceptions.Hubs; - -/// -/// Exception thrown when a connection is not found. -/// -public class ConnectionNotFound(string connectionId) - : InvalidOperationException($"Connection '{connectionId}' not found.") -{ - public string ConnectionId { get; } = connectionId; -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Presentation/Common/Exceptions/Hubs/InvalidRequestFormat.cs b/backend/src/SlideGenerator.Presentation/Common/Exceptions/Hubs/InvalidRequestFormat.cs deleted file mode 100644 index 8e2fd18c..00000000 --- a/backend/src/SlideGenerator.Presentation/Common/Exceptions/Hubs/InvalidRequestFormat.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace SlideGenerator.Presentation.Common.Exceptions.Hubs; - -/// -/// Exception thrown when a request format is invalid. -/// -public class InvalidRequestFormat(string requestType, string? details = null) - : ArgumentException($"Invalid {requestType} request format: {(details != null ? $": {details}" : "")}.") -{ - public string RequestTypeName { get; } = requestType; - public string? Details { get; } = details; -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Presentation/Common/Hubs/Hub.cs b/backend/src/SlideGenerator.Presentation/Common/Hubs/Hub.cs deleted file mode 100644 index 80bd2a76..00000000 --- a/backend/src/SlideGenerator.Presentation/Common/Hubs/Hub.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Serialization; -using SlideGenerator.Presentation.Common.Exceptions.Hubs; - -namespace SlideGenerator.Presentation.Common.Hubs; - -public abstract class Hub : Microsoft.AspNetCore.SignalR.Hub -{ - protected static readonly JsonSerializerOptions SerializerOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - PropertyNameCaseInsensitive = true, - Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) } - }; - - protected T Deserialize(JsonElement message) - { - return message.Deserialize(SerializerOptions) - ?? throw new InvalidRequestFormat(typeof(T).Name); - } -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Presentation/Features/Configs/ConfigHub.cs b/backend/src/SlideGenerator.Presentation/Features/Configs/ConfigHub.cs deleted file mode 100644 index a235da36..00000000 --- a/backend/src/SlideGenerator.Presentation/Features/Configs/ConfigHub.cs +++ /dev/null @@ -1,231 +0,0 @@ -using System.Text.Json; -using Microsoft.AspNetCore.SignalR; -using SlideGenerator.Application.Common.Base.DTOs.Responses; -using SlideGenerator.Application.Features.Configs; -using SlideGenerator.Application.Features.Configs.DTOs.Components; -using SlideGenerator.Application.Features.Configs.DTOs.Requests; -using SlideGenerator.Application.Features.Configs.DTOs.Responses.Errors; -using SlideGenerator.Application.Features.Configs.DTOs.Responses.Successes; -using SlideGenerator.Application.Features.Images; -using SlideGenerator.Application.Features.Jobs.Contracts; -using SlideGenerator.Domain.Configs; -using SlideGenerator.Domain.Features.Jobs.Enums; -using SlideGenerator.Infrastructure.Features.Configs; -using HubBase = SlideGenerator.Presentation.Common.Hubs.Hub; - -namespace SlideGenerator.Presentation.Features.Configs; - -/// -/// SignalR hub for configuration management. -/// -public class ConfigHub( - IJobManager jobManager, - IImageService imageService, - ILogger logger) : HubBase -{ - /// - public override async Task OnConnectedAsync() - { - logger.LogInformation("Client connected: {ConnectionId}", Context.ConnectionId); - await base.OnConnectedAsync(); - } - - /// - public override async Task OnDisconnectedAsync(Exception? exception) - { - logger.LogInformation("Client disconnected: {ConnectionId}", Context.ConnectionId); - await base.OnDisconnectedAsync(exception); - } - - public async Task ProcessRequest(JsonElement message) - { - Response response; - - try - { - var typeStr = message.GetProperty("type").GetString()?.ToLowerInvariant(); - - response = typeStr switch - { - "get" => ExecuteGetConfig(), - "update" => ExecuteUpdateConfig(Deserialize(message)), - "reload" => ExecuteReloadConfig(), - "reset" => ExecuteResetConfig(), - "modelstatus" => ExecuteGetModelStatus(), - "modelcontrol" => await ExecuteModelControlAsync(Deserialize(message)), - _ => throw new ArgumentOutOfRangeException(nameof(typeStr), typeStr, "Unknown config request type") - }; - } - catch (Exception ex) - { - logger.LogError(ex, "Error processing config request"); - response = new ConfigError(ex); - } - - await Clients.Caller.SendAsync("ReceiveResponse", response); - } - - private ConfigGetSuccess ExecuteGetConfig() - { - var config = ConfigHolder.Value; - - return new ConfigGetSuccess( - new ServerConfig(config.Server.Host, config.Server.Port, config.Server.Debug), - new DownloadConfig( - config.Download.MaxChunks, - config.Download.LimitBytesPerSecond, - config.Download.SaveFolder, - new RetryConfig(config.Download.Retry.Timeout, config.Download.Retry.MaxRetries)), - new JobConfig(config.Job.MaxConcurrentJobs), - new ImageConfig( - new FaceConfig( - config.Image.Face.Confidence, - config.Image.Face.UnionAll), - new SaliencyConfig( - config.Image.Saliency.PaddingTop, - config.Image.Saliency.PaddingBottom, - config.Image.Saliency.PaddingLeft, - config.Image.Saliency.PaddingRight)) - ); - } - - private ConfigUpdateSuccess ExecuteUpdateConfig(ConfigUpdate request) - { - if (HasWorkingJobs()) - throw new InvalidOperationException( - "Cannot update config while jobs are running. Pause or complete them first."); - - var config = new Config - { - Server = request.Server != null - ? new Config.ServerConfig - { - Host = request.Server.Host, - Debug = request.Server.Debug, - Port = request.Server.Port - } - : ConfigHolder.Value.Server, - Download = request.Download != null - ? new Config.DownloadConfig - { - MaxChunks = request.Download.MaxChunks, - LimitBytesPerSecond = request.Download.LimitBytesPerSecond, - SaveFolder = request.Download.SaveFolder, - Retry = new Config.DownloadConfig.RetryConfig - { - Timeout = request.Download.Retry.Timeout, - MaxRetries = request.Download.Retry.MaxRetries - } - } - : ConfigHolder.Value.Download, - Job = request.Job != null - ? new Config.JobConfig - { - MaxConcurrentJobs = request.Job.MaxConcurrentJobs - } - : ConfigHolder.Value.Job, - Image = request.Image != null - ? new Config.ImageConfig - { - Face = new Config.ImageConfig.FaceConfig - { - Confidence = request.Image.Face.Confidence, - UnionAll = request.Image.Face.UnionAll - }, - Saliency = new Config.ImageConfig.SaliencyConfig - { - PaddingTop = request.Image.Saliency.PaddingTop, - PaddingBottom = request.Image.Saliency.PaddingBottom, - PaddingLeft = request.Image.Saliency.PaddingLeft, - PaddingRight = request.Image.Saliency.PaddingRight - } - } - : ConfigHolder.Value.Image - }; - ConfigHolder.Value = config; - ConfigLoader.Save(ConfigHolder.Value, ConfigHolder.Locker); - - logger.LogInformation("Configuration updated by client {ConnectionId}", Context.ConnectionId); - return new ConfigUpdateSuccess(true, "Configuration updated successfully"); - } - - private ConfigReloadSuccess ExecuteReloadConfig() - { - if (HasWorkingJobs()) - throw new InvalidOperationException("Cannot reload config while jobs are running."); - - var loaded = ConfigLoader.Load(ConfigHolder.Locker); - if (loaded != null) - ConfigHolder.Value = loaded; - logger.LogInformation("Configuration reloaded by client {ConnectionId}", Context.ConnectionId); - - return new ConfigReloadSuccess(true, "Configuration reloaded successfully"); - } - - private ConfigResetSuccess ExecuteResetConfig() - { - if (HasWorkingJobs()) - throw new InvalidOperationException("Cannot reset config while jobs are running."); - - ConfigHolder.Reset(); - ConfigLoader.Save(ConfigHolder.Value, ConfigHolder.Locker); - logger.LogInformation("Configuration reset to defaults by client {ConnectionId}", Context.ConnectionId); - - return new ConfigResetSuccess(true, "Configuration reset to defaults"); - } - - private ModelStatusSuccess ExecuteGetModelStatus() - { - return new ModelStatusSuccess(imageService.IsFaceModelAvailable); - } - - private async Task ExecuteModelControlAsync(ModelControl request) - { - var model = request.Model.ToLowerInvariant(); - var action = request.Action.ToLowerInvariant(); - - if (model != "face") - throw new ArgumentException($"Unknown model: {request.Model}"); - - bool success; - string message; - - switch (action) - { - case "init": - if (HasWorkingJobs()) - throw new InvalidOperationException("Cannot initialize model while jobs are running."); - await imageService.InitFaceModelAsync(); - success = imageService.IsFaceModelAvailable; - message = success - ? "Face detection model initialized successfully" - : "Failed to initialize face detection model"; - logger.LogInformation("Face model init by client {ConnectionId}: {Success}", Context.ConnectionId, - success); - break; - - case "deinit": - if (HasWorkingJobs()) - throw new InvalidOperationException("Cannot deinitialize model while jobs are running."); - await imageService.DeInitFaceModelAsync(); - success = !imageService.IsFaceModelAvailable; - message = success - ? "Face detection model deinitialized successfully" - : "Failed to deinitialize face detection model"; - logger.LogInformation("Face model deinit by client {ConnectionId}: {Success}", Context.ConnectionId, - success); - break; - - default: - throw new ArgumentException($"Unknown action: {request.Action}"); - } - - return new ModelControlSuccess(request.Model, request.Action, success, message); - } - - private bool HasWorkingJobs() - { - return jobManager.Active.EnumerateGroups() - .Any(group => group.Status is GroupStatus.Pending or GroupStatus.Running); - } -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Presentation/Features/Jobs/JobHub.cs b/backend/src/SlideGenerator.Presentation/Features/Jobs/JobHub.cs deleted file mode 100644 index 1ff31fe5..00000000 --- a/backend/src/SlideGenerator.Presentation/Features/Jobs/JobHub.cs +++ /dev/null @@ -1,486 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Serialization; -using Microsoft.AspNetCore.SignalR; -using SlideGenerator.Application.Common.Base.DTOs.Responses; -using SlideGenerator.Application.Features.Jobs; -using SlideGenerator.Application.Features.Jobs.Contracts; -using SlideGenerator.Application.Features.Jobs.DTOs.Requests; -using SlideGenerator.Application.Features.Jobs.DTOs.Responses.Successes; -using SlideGenerator.Application.Features.Slides; -using SlideGenerator.Application.Features.Slides.DTOs.Components; -using SlideGenerator.Application.Features.Slides.DTOs.Enums; -using SlideGenerator.Application.Features.Slides.DTOs.Requests; -using SlideGenerator.Application.Features.Slides.DTOs.Responses.Errors; -using SlideGenerator.Application.Features.Slides.DTOs.Responses.Successes; -using SlideGenerator.Domain.Features.Jobs.Components; -using SlideGenerator.Domain.Features.Jobs.Enums; -using SlideGenerator.Domain.Features.Jobs.Interfaces; -using SlideGenerator.Domain.Features.Jobs.States; -using HubBase = SlideGenerator.Presentation.Common.Hubs.Hub; - -namespace SlideGenerator.Presentation.Features.Jobs; - -/// -/// SignalR hub for job creation, control, and query. -/// -public class JobHub( - IJobManager jobManager, - ISlideTemplateManager slideTemplateManager, - IJobStateStore jobStateStore, - ILogger logger) : HubBase -{ - private static readonly JsonSerializerOptions JobExportJsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - Converters = { new JsonStringEnumConverter() } - }; - - public Task SubscribeGroup(string groupJobId) - { - return Groups.AddToGroupAsync(Context.ConnectionId, JobSignalRGroups.GroupGroup(groupJobId)); - } - - public Task SubscribeSheet(string sheetJobId) - { - return Groups.AddToGroupAsync(Context.ConnectionId, JobSignalRGroups.SheetGroup(sheetJobId)); - } - - /// - public override async Task OnConnectedAsync() - { - logger.LogInformation("Client connected: {ConnectionId}", Context.ConnectionId); - await base.OnConnectedAsync(); - } - - /// - public override async Task OnDisconnectedAsync(Exception? exception) - { - logger.LogInformation("Client disconnected: {ConnectionId}", Context.ConnectionId); - await base.OnDisconnectedAsync(exception); - } - - public async Task ProcessRequest(JsonElement message) - { - Response response; - - try - { - var typeStr = message.GetProperty("type").GetString()?.ToLowerInvariant(); - - response = typeStr switch - { - "scanshapes" => ExecuteScanShapes( - Deserialize(message)), - "scanplaceholders" => ExecuteScanPlaceholders( - Deserialize(message)), - "scantemplate" => ExecuteScanTemplate( - Deserialize(message)), - "taskcreate" or "jobcreate" => ExecuteJobCreate( - Deserialize(message)), - "taskcontrol" or "jobcontrol" => ExecuteJobControl( - Deserialize(message)), - "taskquery" or "jobquery" => ExecuteJobQuery( - Deserialize(message)), - _ => throw new ArgumentOutOfRangeException(nameof(typeStr), typeStr, "Unknown request type") - }; - } - catch (Exception ex) - { - logger.LogError(ex, "Error processing presentation request"); - response = new Error(ex); - } - - await Clients.Caller.SendAsync("ReceiveResponse", response); - } - - private SlideScanShapesSuccess ExecuteScanShapes(SlideScanShapes request) - { - var added = slideTemplateManager.AddTemplate(request.FilePath); - try - { - var template = slideTemplateManager.GetTemplate(request.FilePath); - - var imageShapes = template.GetAllImageShapes(); - var shapes = template.GetAllShapes() - .Select(shape => - { - var data = imageShapes.TryGetValue(shape.Id, out var preview) - ? Convert.ToBase64String(preview.Image) - : string.Empty; - return new ShapeDto(shape.Id, shape.Name, data, shape.Kind, shape.IsImage); - }) - .ToArray(); - - return new SlideScanShapesSuccess(request.FilePath, shapes); - } - finally - { - if (added) slideTemplateManager.RemoveTemplate(request.FilePath); - } - } - - private SlideScanPlaceholdersSuccess ExecuteScanPlaceholders(SlideScanPlaceholders request) - { - var added = slideTemplateManager.AddTemplate(request.FilePath); - try - { - var template = slideTemplateManager.GetTemplate(request.FilePath); - - var placeholders = template.GetAllTextPlaceholders().ToArray(); - return new SlideScanPlaceholdersSuccess(request.FilePath, placeholders); - } - finally - { - if (added) slideTemplateManager.RemoveTemplate(request.FilePath); - } - } - - private SlideScanTemplateSuccess ExecuteScanTemplate(SlideScanTemplate request) - { - var added = slideTemplateManager.AddTemplate(request.FilePath); - try - { - var template = slideTemplateManager.GetTemplate(request.FilePath); - - var imageShapes = template.GetAllImageShapes(); - var shapes = template.GetAllShapes() - .Select(shape => - { - var data = imageShapes.TryGetValue(shape.Id, out var preview) - ? Convert.ToBase64String(preview.Image) - : string.Empty; - return new ShapeDto(shape.Id, shape.Name, data, shape.Kind, shape.IsImage); - }) - .ToArray(); - - var placeholders = template.GetAllTextPlaceholders().ToArray(); - return new SlideScanTemplateSuccess(request.FilePath, shapes, placeholders); - } - finally - { - if (added) slideTemplateManager.RemoveTemplate(request.FilePath); - } - } - - private JobCreateSuccess ExecuteJobCreate(JobCreate request) - { - if (string.IsNullOrWhiteSpace(request.TemplatePath)) - throw new InvalidOperationException("TemplatePath is required."); - if (string.IsNullOrWhiteSpace(request.SpreadsheetPath)) - throw new InvalidOperationException("SpreadsheetPath is required."); - if (string.IsNullOrWhiteSpace(request.OutputPath)) - throw new InvalidOperationException("OutputPath is required."); - - if (request.JobType == JobType.Sheet && string.IsNullOrWhiteSpace(request.SheetName)) - throw new InvalidOperationException("SheetName is required for sheet jobs."); - - logger.LogInformation( - "Creating job: Type={JobType}, Template={TemplatePath}, Spreadsheet={SpreadsheetPath}, AutoStart={AutoStart}", - request.JobType, request.TemplatePath, request.SpreadsheetPath, request.AutoStart); - - var group = jobManager.Active.CreateGroup(request); - if (request.AutoStart) - jobManager.Active.StartGroup(group.Id); - - logger.LogInformation("Job group created: {GroupId} with {SheetCount} sheets", - group.Id, group.Sheets.Count); - - if (request.JobType == JobType.Sheet) - { - var sheet = group.Sheets.Values.First(s => - string.Equals(s.SheetName, request.SheetName, StringComparison.OrdinalIgnoreCase)); - - return new JobCreateSuccess(BuildJobSummary(sheet), null); - } - - var jobIds = new Dictionary(group.Sheets.Count); - foreach (var kv in group.Sheets) - jobIds[kv.Value.SheetName] = kv.Key; - - return new JobCreateSuccess(BuildJobSummary(group), jobIds); - } - - private JobQuerySuccess ExecuteJobQuery(JobQuery request) - { - if (!string.IsNullOrWhiteSpace(request.JobId)) - { - var (jobType, group, sheet) = ResolveJob(request.JobId, request.JobType); - var payload = request.IncludePayload - ? jobType == JobType.Group - ? GetGroupPayload(request.JobId) - : GetSheetPayload(request.JobId) - : null; - - var detail = jobType == JobType.Group - ? BuildJobDetail(group!, request.IncludeSheets, payload) - : BuildJobDetail(sheet!, payload); - - return new JobQuerySuccess(detail, null); - } - - var includeGroups = request.JobType != JobType.Sheet; - var includeSheets = request.JobType != JobType.Group; - - var jobs = new List(); - if (request.Scope is JobQueryScope.Active or JobQueryScope.All) - { - if (includeGroups) - jobs.AddRange(jobManager.Active.EnumerateGroups().Select(BuildJobSummary)); - if (includeSheets) - jobs.AddRange(jobManager.Active.EnumerateSheets().Select(BuildJobSummary)); - } - - if (request.Scope is JobQueryScope.Completed or JobQueryScope.All) - { - if (includeGroups) - jobs.AddRange(jobManager.Completed.EnumerateGroups().Select(BuildJobSummary)); - if (includeSheets) - jobs.AddRange(jobManager.Completed.EnumerateSheets().Select(BuildJobSummary)); - } - - return new JobQuerySuccess(null, jobs); - } - - private JobControlSuccess ExecuteJobControl(JobControl request) - { - var (jobType, group, sheet) = ResolveJob(request.JobId, request.JobType); - var action = request.Action == ControlAction.Stop ? ControlAction.Cancel : request.Action; - - logger.LogInformation("Job control: {Action} on {JobType} {JobId}", - action, jobType, request.JobId); - - switch (jobType) - { - case JobType.Group: - switch (action) - { - case ControlAction.Pause: - jobManager.Active.PauseGroup(group!.Id); - break; - case ControlAction.Resume: - jobManager.Active.ResumeGroup(group!.Id); - break; - case ControlAction.Cancel: - jobManager.Active.CancelGroup(group!.Id); - break; - case ControlAction.Remove: - if (jobManager.Active.ContainsGroup(group!.Id)) - jobManager.Active.CancelAndRemoveGroup(group.Id); - else - jobManager.Completed.RemoveGroup(group.Id); - break; - } - - break; - case JobType.Sheet: - switch (action) - { - case ControlAction.Pause: - jobManager.Active.PauseSheet(sheet!.Id); - break; - case ControlAction.Resume: - jobManager.Active.ResumeSheet(sheet!.Id); - break; - case ControlAction.Cancel: - jobManager.Active.CancelSheet(sheet!.Id); - break; - case ControlAction.Remove: - if (jobManager.Active.ContainsSheet(sheet!.Id)) - jobManager.Active.CancelAndRemoveSheet(sheet.Id); - else - jobManager.Completed.RemoveSheet(sheet.Id); - break; - } - - break; - } - - return new JobControlSuccess(request.JobId, jobType, action); - } - - private static JobSummary BuildJobSummary(IJobGroup group) - { - return new JobSummary( - group.Id, - JobType.Group, - group.Status.ToJobState(), - group.Progress, - null, - null, - group.OutputFolder.FullName, - group.ErrorCount, - null); - } - - private static JobSummary BuildJobSummary(IJobSheet sheet) - { - return new JobSummary( - sheet.Id, - JobType.Sheet, - sheet.Status.ToJobState(), - sheet.Progress, - sheet.GroupId, - sheet.SheetName, - sheet.OutputPath, - sheet.ErrorCount, - sheet.HangfireJobId); - } - - private static JobDetail BuildJobDetail(IJobGroup group, bool includeSheets, string? payloadJson) - { - IReadOnlyDictionary? sheets = null; - if (includeSheets) - sheets = group.Sheets.ToDictionary( - kv => kv.Key, - kv => BuildJobSummary(kv.Value)); - - return new JobDetail( - group.Id, - JobType.Group, - group.Status.ToJobState(), - group.Progress, - group.ErrorCount, - null, - null, - null, - null, - null, - null, - group.OutputFolder.FullName, - sheets, - payloadJson, - null); - } - - private static JobDetail BuildJobDetail(IJobSheet sheet, string? payloadJson) - { - return new JobDetail( - sheet.Id, - JobType.Sheet, - sheet.Status.ToJobState(), - sheet.Progress, - sheet.ErrorCount, - sheet.ErrorMessage, - sheet.GroupId, - sheet.SheetName, - sheet.CurrentRow, - sheet.TotalRows, - sheet.OutputPath, - null, - null, - payloadJson, - sheet.HangfireJobId); - } - - private (JobType JobType, IJobGroup? Group, IJobSheet? Sheet) ResolveJob(string jobId, JobType? jobType) - { - if (jobType == JobType.Group) - { - var group = jobManager.GetGroup(jobId) - ?? throw new InvalidOperationException($"Group job {jobId} not found"); - return (JobType.Group, group, null); - } - - if (jobType == JobType.Sheet) - { - var sheet = jobManager.GetSheet(jobId) - ?? throw new InvalidOperationException($"Sheet job {jobId} not found"); - return (JobType.Sheet, null, sheet); - } - - var resolvedGroup = jobManager.GetGroup(jobId); - if (resolvedGroup != null) - return (JobType.Group, resolvedGroup, null); - - var resolvedSheet = jobManager.GetSheet(jobId); - if (resolvedSheet != null) - return (JobType.Sheet, null, resolvedSheet); - - throw new InvalidOperationException($"Job {jobId} not found"); - } - - private string? GetGroupPayload(string groupId) - { - var groupState = jobStateStore.GetGroupAsync(groupId, CancellationToken.None) - .GetAwaiter().GetResult(); - if (groupState == null) - return null; - - var sheets = jobStateStore.GetSheetsByGroupAsync(groupId, CancellationToken.None) - .GetAwaiter().GetResult(); - return BuildGroupPayload(groupState, sheets); - } - - private string? GetSheetPayload(string sheetId) - { - var sheetState = jobStateStore.GetSheetAsync(sheetId, CancellationToken.None) - .GetAwaiter().GetResult(); - if (sheetState == null) - return null; - - var groupState = jobStateStore.GetGroupAsync(sheetState.GroupId, CancellationToken.None) - .GetAwaiter().GetResult(); - return BuildSheetPayload(sheetState, groupState); - } - - private static string BuildGroupPayload( - GroupJobState groupState, - IReadOnlyList sheetStates) - { - var sheetNames = sheetStates.Select(s => s.SheetName).Distinct().ToArray(); - var firstSheet = sheetStates.FirstOrDefault(); - var textConfigs = firstSheet != null ? MapTextConfigs(firstSheet.TextConfigs) : null; - var imageConfigs = firstSheet != null ? MapImageConfigs(firstSheet.ImageConfigs) : null; - var payload = new JobExportPayload( - JobType.Group, - groupState.TemplatePath, - groupState.WorkbookPath, - groupState.OutputFolderPath, - sheetNames, - null, - textConfigs, - imageConfigs); - - return JsonSerializer.Serialize(payload, JobExportJsonOptions); - } - - private static string BuildSheetPayload( - SheetJobState sheetState, - GroupJobState? groupState) - { - var payload = new JobExportPayload( - JobType.Sheet, - groupState?.TemplatePath ?? string.Empty, - groupState?.WorkbookPath ?? string.Empty, - sheetState.OutputPath, - null, - sheetState.SheetName, - MapTextConfigs(sheetState.TextConfigs), - MapImageConfigs(sheetState.ImageConfigs)); - - return JsonSerializer.Serialize(payload, JobExportJsonOptions); - } - - private static SlideTextConfig[]? MapTextConfigs(JobTextConfig[] configs) - { - if (configs.Length == 0) return null; - return [.. configs.Select(c => new SlideTextConfig(c.Pattern, c.Columns))]; - } - - private static SlideImageConfig[]? MapImageConfigs(JobImageConfig[] configs) - { - if (configs.Length == 0) return null; - return [.. configs.Select(c => new SlideImageConfig(c.ShapeId, c.Columns, c.RoiType, c.CropType))]; - } - - private sealed record JobExportPayload( - JobType JobType, - string TemplatePath, - string SpreadsheetPath, - string OutputPath, - string[]? SheetNames, - string? SheetName, - SlideTextConfig[]? TextConfigs, - SlideImageConfig[]? ImageConfigs); -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Presentation/Features/Sheets/SheetHub.cs b/backend/src/SlideGenerator.Presentation/Features/Sheets/SheetHub.cs deleted file mode 100644 index 126a4920..00000000 --- a/backend/src/SlideGenerator.Presentation/Features/Sheets/SheetHub.cs +++ /dev/null @@ -1,165 +0,0 @@ -using System.Collections.Concurrent; -using System.Text.Json; -using Microsoft.AspNetCore.SignalR; -using SlideGenerator.Application.Common.Base.DTOs.Responses; -using SlideGenerator.Application.Features.Sheets; -using SlideGenerator.Application.Features.Sheets.DTOs.Components; -using SlideGenerator.Application.Features.Sheets.DTOs.Requests.Workbook; -using SlideGenerator.Application.Features.Sheets.DTOs.Requests.Worksheet; -using SlideGenerator.Application.Features.Sheets.DTOs.Responses.Errors; -using SlideGenerator.Application.Features.Sheets.DTOs.Responses.Successes.Workbook; -using SlideGenerator.Application.Features.Sheets.DTOs.Responses.Successes.Worksheet; -using SlideGenerator.Domain.Features.Sheets.Interfaces; -using SlideGenerator.Presentation.Common.Exceptions.Hubs; -using HubBase = SlideGenerator.Presentation.Common.Hubs.Hub; - -namespace SlideGenerator.Presentation.Features.Sheets; - -/// -/// SignalR Hub for spreadsheet operations. -/// -public class SheetHub(ISheetService sheetService, ILogger logger) : HubBase -{ - private static readonly ConcurrentDictionary> - WorkbooksOfConnections = new(); - - private ConcurrentDictionary Workbooks - => WorkbooksOfConnections.GetValueOrDefault(Context.ConnectionId) - ?? throw new ConnectionNotFound(Context.ConnectionId); - - /// - public override async Task OnConnectedAsync() - { - logger.LogInformation("Client connected: {ConnectionId}", Context.ConnectionId); - WorkbooksOfConnections[Context.ConnectionId] = new ConcurrentDictionary(); - await base.OnConnectedAsync(); - } - - /// - public override async Task OnDisconnectedAsync(Exception? exception) - { - logger.LogInformation("Client disconnected: {ConnectionId}", Context.ConnectionId); - - // Cleanup open workbooks for this connection - if (WorkbooksOfConnections.TryRemove(Context.ConnectionId, out var workbooks)) - foreach (var key in workbooks.Keys) - if (workbooks.TryRemove(key, out var wb)) - wb.Dispose(); - - await base.OnDisconnectedAsync(exception); - } - - /// - /// Processes a sheet request based on type. - /// - public async Task ProcessRequest(JsonElement message) - { - Response response; - var filePath = string.Empty; - - try - { - var typeStr = message.GetProperty("type").GetString()?.ToLowerInvariant(); - filePath = message.GetProperty("filePath").GetString() ?? string.Empty; - - response = typeStr switch - { - "openfile" => ExecuteOpenFile( - Deserialize(message)), - "closefile" => ExecuteCloseFile( - Deserialize(message)), - "gettables" => ExecuteGetSheets( - Deserialize(message)), - "getheaders" => ExecuteGetHeaders( - Deserialize(message)), - "getrow" => ExecuteGetRow( - Deserialize(message)), - "getworkbookinfo" => ExecuteGetWorkbookInfo( - Deserialize(message)), - _ => throw new ArgumentOutOfRangeException(nameof(typeStr), typeStr, null) - }; - } - catch (Exception ex) - { - logger.LogError(ex, "Error processing sheet request"); - response = new SheetError(filePath, ex); - } - - await Clients.Caller.SendAsync("ReceiveResponse", response); - } - - private OpenBookSheetSuccess ExecuteOpenFile(SheetWorkbookOpen request) - { - GetOrOpenWorkbook(request.FilePath); - return new OpenBookSheetSuccess(request.FilePath); - } - - private SheetWorkbookCloseSuccess ExecuteCloseFile(SheetWorkbookClose request) - { - if (Workbooks.TryRemove(request.FilePath, out var wb)) - wb.Dispose(); - - return new SheetWorkbookCloseSuccess(request.FilePath); - } - - private SheetWorkbookGetSheetInfoSuccess ExecuteGetSheets(SheetWorkbookGetSheetInfo request) - { - var workbook = GetOrOpenWorkbook(request.FilePath); - - return new SheetWorkbookGetSheetInfoSuccess - ( - request.FilePath, - sheetService.GetSheetsInfo(workbook) - ); - } - - private SheetWorksheetGetHeadersSuccess ExecuteGetHeaders(SheetWorksheetGetHeaders request) - { - var workbook = GetOrOpenWorkbook(request.FilePath); - - return new SheetWorksheetGetHeadersSuccess - ( - request.FilePath, - request.SheetName, - sheetService.GetHeaders(workbook, request.SheetName) - ); - } - - private SheetWorksheetGetRowSuccess ExecuteGetRow(SheetWorksheetGetRow request) - { - var workbook = GetOrOpenWorkbook(request.FilePath); - - return new SheetWorksheetGetRowSuccess - ( - request.FilePath, - request.TableName, - request.RowNumber, - sheetService.GetRow(workbook, request.TableName, request.RowNumber) - ); - } - - private SheetWorkbookGetInfoSuccess ExecuteGetWorkbookInfo(GetWorkbookInfoRequest request) - { - var workbook = GetOrOpenWorkbook(request.FilePath); - var sheetsInfo = sheetService.GetSheetsInfo(workbook); - - var sheets = new List(); - foreach (var (sheetName, rowCount) in sheetsInfo) - { - var headers = sheetService.GetHeaders(workbook, sheetName); - sheets.Add(new SheetWorksheetInfo(sheetName, headers, rowCount)); - } - - return new SheetWorkbookGetInfoSuccess(request.FilePath, workbook.Name, sheets); - } - - private ISheetBook GetOrOpenWorkbook(string sheetPath) - { - if (Workbooks.TryGetValue(sheetPath, out var workbook)) - return workbook; - workbook = sheetService.OpenFile(sheetPath); - Workbooks[sheetPath] = workbook; - - return workbook; - } -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Presentation/Program.cs b/backend/src/SlideGenerator.Presentation/Program.cs deleted file mode 100644 index da0b3979..00000000 --- a/backend/src/SlideGenerator.Presentation/Program.cs +++ /dev/null @@ -1,168 +0,0 @@ -using System.Text.Json; -using Hangfire; -using Hangfire.Storage.SQLite; -using SlideGenerator.Application.Features.Configs; -using SlideGenerator.Application.Features.Downloads; -using SlideGenerator.Application.Features.Images; -using SlideGenerator.Application.Features.Jobs.Contracts; -using SlideGenerator.Application.Features.Sheets; -using SlideGenerator.Application.Features.Slides; -using SlideGenerator.Domain.Configs; -using SlideGenerator.Domain.Features.Downloads; -using SlideGenerator.Domain.Features.IO; -using SlideGenerator.Domain.Features.Jobs.Interfaces; -using SlideGenerator.Infrastructure.Common.Logging; -using SlideGenerator.Infrastructure.Features.Configs; -using SlideGenerator.Infrastructure.Features.Downloads.Services; -using SlideGenerator.Infrastructure.Features.Images.Services; -using SlideGenerator.Infrastructure.Features.IO; -using SlideGenerator.Infrastructure.Features.Jobs.Hangfire; -using SlideGenerator.Infrastructure.Features.Jobs.Services; -using SlideGenerator.Infrastructure.Features.Sheets.Services; -using SlideGenerator.Infrastructure.Features.Slides.Services; -using SlideGenerator.Presentation.Features.Configs; -using SlideGenerator.Presentation.Features.Jobs; -using SlideGenerator.Presentation.Features.Sheets; - -namespace SlideGenerator.Presentation; - -/// -/// The main class of SlideGenerator Presentation layer. -/// -public static class Program -{ - private static void LoadConfig() - { - var loaded = ConfigLoader.Load(ConfigHolder.Locker); - if (loaded != null) - ConfigHolder.Value = loaded; - else ConfigLoader.Save(ConfigHolder.Value, ConfigHolder.Locker); - } - - private static WebApplicationBuilder InitializeBuilder(string[] args) - { - var builder = WebApplication.CreateBuilder(args); - - // Configure Serilog from Infrastructure - builder.AddInfrastructureLogging(); - - builder.Services.AddSignalR(options => options.EnableDetailedErrors = ConfigHolder.Value.Server.Debug) - .AddJsonProtocol(options => - { - options.PayloadSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; - options.PayloadSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.CamelCase; - }); - builder.Services.AddHttpClient(); - builder.Services.AddLogging(); - - // Application Services - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(sp => (IDownloadClient)sp.GetRequiredService()); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(sp => sp.GetRequiredService()); - builder.Services.AddSingleton(); - - // Job Services - builder.Services.AddSingleton(); - builder.Services.AddSingleton(sp => sp.GetRequiredService()); - builder.Services.AddSingleton>(); - builder.Services.AddScoped(); - builder.Services.AddSingleton(); - builder.Services.AddHostedService(); - - // Hangfire Setup - var dbPath = Config.DefaultDatabasePath; - builder.Services.AddHangfire(configuration => configuration - .SetDataCompatibilityLevel(CompatibilityLevel.Version_180) - .UseSimpleAssemblyNameTypeSerializer() - .UseRecommendedSerializerSettings() - .UseSQLiteStorage(dbPath)); - builder.Services.AddHangfireServer(options => - { - options.WorkerCount = ConfigHolder.Value.Job.MaxConcurrentJobs; - }); - - builder.Services.AddCors(options => - { - options.AddDefaultPolicy(policy => - { - policy.AllowAnyHeader() - .AllowAnyMethod() - .AllowAnyOrigin(); - }); - }); - - return builder; - } - - private static WebApplication InitializeApp(WebApplicationBuilder builder) - { - var app = builder.Build(); - app.UseCors(); - app.UseWebSockets(); - - app.MapHub("/hubs/sheet"); - app.MapHub("/hubs/job"); - app.MapHub("/hubs/task"); - app.MapHub("/hubs/config"); - - app.MapGet("/", () => new - { - Name = Config.AppName, - Description = Config.AppDescription, - Repository = Config.AppUrl - }); - app.MapGet("/health", () => Results.Ok(new { IsRunning = true })); - app.UseHangfireDashboard("/dashboard", new DashboardOptions - { - DashboardTitle = Config.AppName, - Authorization = [], - IsReadOnlyFunc = _ => true, - DisplayNameFunc = (_, job) => - { - if (job.Args is { Count: > 0 } && job.Args[0] is string sheetId) - { - var displayName = SheetJobNameRegistry.GetDisplayName(sheetId); - if (!string.IsNullOrEmpty(displayName)) - return displayName; - } - - return $"{job.Type.Name}.{job.Method.Name}"; - } - }); - - // Get host/port - var host = ConfigHolder.Value.Server.Host; - app.Urls.Clear(); - app.Urls.Add($"http://{host}:{ConfigHolder.Value.Server.Port}"); - - // On Application Stopping - app.Lifetime.ApplicationStopping.Register(() => - { - ConfigLoader.Save(ConfigHolder.Value, ConfigHolder.Locker); - }); - - return app; - } - - private static async Task Main(string[] args) - { - LoadConfig(); - - try - { - var builder = InitializeBuilder(args); - var app = InitializeApp(builder); - await app.RunAsync(); - } - finally - { - ConfigLoader.Save(ConfigHolder.Value, ConfigHolder.Locker); - await LoggingExtensions.CloseAndFlushAsync(); - } - } -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Presentation/Properties/launchSettings.json b/backend/src/SlideGenerator.Presentation/Properties/launchSettings.json deleted file mode 100644 index 61b6db9a..00000000 --- a/backend/src/SlideGenerator.Presentation/Properties/launchSettings.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "profiles": { - "http": { - "commandName": "Project", - "workingDirectory": "$(SolutionDir)", - "launchBrowser": true, - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - }, - "dotnetRunMessages": true, - "applicationUrl": "http://localhost:5262" - }, - "https": { - "commandName": "Project", - "workingDirectory": "$(SolutionDir)", - "launchBrowser": true, - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - }, - "dotnetRunMessages": true, - "applicationUrl": "https://localhost:7227;http://localhost:5262" - }, - "Container (Dockerfile)": { - "commandName": "Docker", - "launchBrowser": true, - "launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}", - "environmentVariables": { - "ASPNETCORE_HTTPS_PORTS": "8081", - "ASPNETCORE_HTTP_PORTS": "8080" - }, - "publishAllPorts": true, - "useSSL": true, - "containerName": "SlideGeneratorBackend" - } - }, - "$schema": "https://json.schemastore.org/launchsettings.json" -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Presentation/SlideGenerator.Presentation.csproj b/backend/src/SlideGenerator.Presentation/SlideGenerator.Presentation.csproj deleted file mode 100644 index 0d9a49b7..00000000 --- a/backend/src/SlideGenerator.Presentation/SlideGenerator.Presentation.csproj +++ /dev/null @@ -1,21 +0,0 @@ - - - - net10.0 - enable - enable - 22b3b648-7b0b-493b-a09d-be022aaf21bd - Linux - ..\.. - true - GPL-3.0-only - $(NoWarn);1591 - - - - - - - - - \ No newline at end of file diff --git a/backend/src/SlideGenerator.Scanning/Models/Sheets/Workbook.cs b/backend/src/SlideGenerator.Scanning/Models/Sheets/Workbook.cs new file mode 100644 index 00000000..da02cda5 --- /dev/null +++ b/backend/src/SlideGenerator.Scanning/Models/Sheets/Workbook.cs @@ -0,0 +1,8 @@ +namespace SlideGenerator.Scanning.Models.Sheets; + +/// +/// Represents worksheet scan response payload. +/// +/// Scanned spreadsheet file path. +/// Collection of worksheet scan items. +public sealed record Workbook(string FilePath, IReadOnlyList Sheets); \ No newline at end of file diff --git a/backend/src/SlideGenerator.Scanning/Models/Sheets/Worksheet.cs b/backend/src/SlideGenerator.Scanning/Models/Sheets/Worksheet.cs new file mode 100644 index 00000000..cde2ca6a --- /dev/null +++ b/backend/src/SlideGenerator.Scanning/Models/Sheets/Worksheet.cs @@ -0,0 +1,9 @@ +namespace SlideGenerator.Scanning.Models.Sheets; + +/// +/// Represents scan result for a single worksheet. +/// +/// Worksheet name. +/// Detected header values. +/// Detected data row count. +public sealed record Worksheet(string SheetName, IReadOnlyList Headers, int RecordCount); \ No newline at end of file diff --git a/backend/src/SlideGenerator.Scanning/Models/Slides/Presentation.cs b/backend/src/SlideGenerator.Scanning/Models/Slides/Presentation.cs new file mode 100644 index 00000000..0b3e452f --- /dev/null +++ b/backend/src/SlideGenerator.Scanning/Models/Slides/Presentation.cs @@ -0,0 +1,8 @@ +namespace SlideGenerator.Scanning.Models.Slides; + +/// +/// Represents slide scan response payload. +/// +/// Scanned presentation file path. +/// Collection of slide scan items. +public sealed record Presentation(string FilePath, IReadOnlyList Slides); \ No newline at end of file diff --git a/backend/src/SlideGenerator.Scanning/Models/Slides/Slide.cs b/backend/src/SlideGenerator.Scanning/Models/Slides/Slide.cs new file mode 100644 index 00000000..373b5e1b --- /dev/null +++ b/backend/src/SlideGenerator.Scanning/Models/Slides/Slide.cs @@ -0,0 +1,12 @@ +namespace SlideGenerator.Scanning.Models.Slides; + +/// +/// Represents scan result for a single slide. +/// +/// 1-based slide index. +/// Detected image-capable shape ids. +/// Detected text placeholders. +public sealed record Slide( + int Index, + IReadOnlyList Placeholders, + IReadOnlyList ImageShapeIds); \ No newline at end of file diff --git a/backend/src/SlideGenerator.Scanning/Services/ScanService.cs b/backend/src/SlideGenerator.Scanning/Services/ScanService.cs new file mode 100644 index 00000000..8dc834c2 --- /dev/null +++ b/backend/src/SlideGenerator.Scanning/Services/ScanService.cs @@ -0,0 +1,68 @@ +using SlideGenerator.Framework.Sheet.Services; +using SlideGenerator.Framework.Slide.Services; +using SlideGenerator.Scanning.Models.Sheets; +using SlideGenerator.Scanning.Models.Slides; + +namespace SlideGenerator.Scanning.Services; + +public sealed class ScanService +{ + public static async Task ScanPresentationAsync(string filePath, CancellationToken cancellationToken) + { + filePath = Path.GetFullPath(filePath); + if (!File.Exists(filePath)) + throw new FileNotFoundException("Slide template file not found.", filePath); + + using var document = XmlPresentationService.OpenOrCreatePresentation(filePath, false); + + var result = new List(); + foreach (var slidePart in XmlPresentationService.EnumerateSlides(document)) + { + cancellationToken.ThrowIfCancellationRequested(); + + var placeholders = TextReplacer.GetUniquePlaceholders(slidePart); + var imageIds = ShapeService.GetImageShapeIds(slidePart); + + result.Add(new Slide(result.Count, placeholders, imageIds)); + } + + return await Task.FromResult(new Presentation(filePath, result)); + } + + public static Task ScanWorkbookAsync(string filePath, CancellationToken cancellationToken) + { + try + { + filePath = Path.GetFullPath(filePath); + if (!File.Exists(filePath)) + throw new FileNotFoundException("Sheet data file not found.", filePath); + + using var workbook = WorkbookService.OpenWorkbook(filePath); + var sheets = new List(); + foreach (var sheet in workbook.Worksheets) + { + cancellationToken.ThrowIfCancellationRequested(); + + var contentRange = WorksheetService.GetContentRange(sheet); + if (contentRange == null) + { + sheets.Add(new Worksheet(sheet.Name, [], 0)); + continue; + } + + var headers = contentRange.FirstRow().Cells() + .Select(cell => cell.GetString()) + .Where(value => !string.IsNullOrWhiteSpace(value)) + .ToList(); + var recordCount = Math.Max(0, contentRange.RowCount() - 1); + sheets.Add(new Worksheet(sheet.Name, headers, recordCount)); + } + + return Task.FromResult(new Workbook(filePath, sheets)); + } + catch (Exception exception) + { + return Task.FromException(exception); + } + } +} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Scanning/SlideGenerator.Scanning.csproj b/backend/src/SlideGenerator.Scanning/SlideGenerator.Scanning.csproj new file mode 100644 index 00000000..1b66ecca --- /dev/null +++ b/backend/src/SlideGenerator.Scanning/SlideGenerator.Scanning.csproj @@ -0,0 +1,23 @@ + + + + net10.0 + enable + enable + true + GPL-3.0-only + $(NoWarn);1591 + false + + + + + + + + + + + + + diff --git a/backend/tests/SlideGenerator.Tests/Domain/JobConfigTests.cs b/backend/tests/SlideGenerator.Tests/Domain/JobConfigTests.cs deleted file mode 100644 index dffbf172..00000000 --- a/backend/tests/SlideGenerator.Tests/Domain/JobConfigTests.cs +++ /dev/null @@ -1,14 +0,0 @@ -using SlideGenerator.Domain.Configs; - -namespace SlideGenerator.Tests.Domain; - -[TestClass] -public sealed class JobConfigTests -{ - [TestMethod] - public void Defaults_MaxConcurrentJobsIsFive() - { - var config = new Config.JobConfig(); - Assert.AreEqual(5, config.MaxConcurrentJobs); - } -} \ No newline at end of file diff --git a/backend/tests/SlideGenerator.Tests/Domain/JobGroupTests.cs b/backend/tests/SlideGenerator.Tests/Domain/JobGroupTests.cs deleted file mode 100644 index 8892bc7d..00000000 --- a/backend/tests/SlideGenerator.Tests/Domain/JobGroupTests.cs +++ /dev/null @@ -1,100 +0,0 @@ -using SlideGenerator.Domain.Features.Jobs.Entities; -using SlideGenerator.Domain.Features.Jobs.Enums; -using SlideGenerator.Tests.Helpers; - -namespace SlideGenerator.Tests.Domain; - -[TestClass] -public sealed class JobGroupTests -{ - [TestMethod] - public void UpdateStatus_FailedBeatsAll() - { - var group = CreateGroup(out var sheet1, out var sheet2); - sheet1.SetStatus(SheetJobStatus.Completed); - sheet2.SetStatus(SheetJobStatus.Failed); - - group.UpdateStatus(); - - Assert.AreEqual(GroupStatus.Failed, group.Status); - } - - [TestMethod] - public void UpdateStatus_RunningBeatsPaused() - { - var group = CreateGroup(out var sheet1, out var sheet2); - sheet1.SetStatus(SheetJobStatus.Paused); - sheet2.SetStatus(SheetJobStatus.Running); - - group.UpdateStatus(); - - Assert.AreEqual(GroupStatus.Running, group.Status); - } - - [TestMethod] - public void UpdateStatus_PausedWhenAnyPaused() - { - var group = CreateGroup(out var sheet1, out var sheet2); - sheet1.SetStatus(SheetJobStatus.Paused); - sheet2.SetStatus(SheetJobStatus.Pending); - - group.UpdateStatus(); - - Assert.AreEqual(GroupStatus.Paused, group.Status); - } - - [TestMethod] - public void UpdateStatus_CancelledWhenOnlyCancelledOrCompleted() - { - var group = CreateGroup(out var sheet1, out var sheet2); - sheet1.SetStatus(SheetJobStatus.Completed); - sheet2.SetStatus(SheetJobStatus.Cancelled); - - group.UpdateStatus(); - - Assert.AreEqual(GroupStatus.Cancelled, group.Status); - } - - [TestMethod] - public void UpdateStatus_CompletedWhenAllCompleted() - { - var group = CreateGroup(out var sheet1, out var sheet2); - sheet1.SetStatus(SheetJobStatus.Completed); - sheet2.SetStatus(SheetJobStatus.Completed); - - group.UpdateStatus(); - - Assert.AreEqual(GroupStatus.Completed, group.Status); - } - - [TestMethod] - public void Progress_UsesTotalRowsAcrossSheets() - { - var group = CreateGroup(out var sheet1, out var sheet2); - - sheet1.UpdateProgress(5); // 50% of 10 - sheet2.UpdateProgress(5); // 25% of 20 - - Assert.AreEqual(33.33f, group.Progress, 0.05f); - } - - private static JobGroup CreateGroup(out JobSheet sheet1, out JobSheet sheet2) - { - var workbook = new TestSheetBook("book.xlsx", - new TestSheet("Sheet1", 10), - new TestSheet("Sheet2", 20)); - var template = new TestTemplatePresentation("template.pptx"); - - var group = new JobGroup( - workbook, - template, - new DirectoryInfo(Path.GetTempPath()), - [], - []); - - sheet1 = group.AddJob("Sheet1", "sheet1.pptx"); - sheet2 = group.AddJob("Sheet2", "sheet2.pptx"); - - return group; - } -} \ No newline at end of file diff --git a/backend/tests/SlideGenerator.Tests/Domain/JobSheetTests.cs b/backend/tests/SlideGenerator.Tests/Domain/JobSheetTests.cs deleted file mode 100644 index 9da2ec06..00000000 --- a/backend/tests/SlideGenerator.Tests/Domain/JobSheetTests.cs +++ /dev/null @@ -1,63 +0,0 @@ -using SlideGenerator.Domain.Features.Jobs.Entities; -using SlideGenerator.Domain.Features.Jobs.Enums; -using SlideGenerator.Tests.Helpers; - -namespace SlideGenerator.Tests.Domain; - -[TestClass] -public sealed class JobSheetTests -{ - [TestMethod] - public void UpdateProgress_ClampsToRowCount() - { - var sheet = CreateSheet(5); - - sheet.UpdateProgress(-3); - Assert.AreEqual(0, sheet.CurrentRow); - - sheet.UpdateProgress(10); - Assert.AreEqual(5, sheet.CurrentRow); - } - - [TestMethod] - public void NextRowIndex_TracksCurrentRow() - { - var sheet = CreateSheet(5); - sheet.UpdateProgress(2); - - Assert.AreEqual(3, sheet.NextRowIndex); - } - - [TestMethod] - public void Pause_SetsStatusPaused() - { - var sheet = CreateSheet(3); - - sheet.SetStatus(SheetJobStatus.Running); - sheet.Pause(); - - Assert.AreEqual(SheetJobStatus.Paused, sheet.Status); - } - - [TestMethod] - public void RegisterRowError_IncrementsErrorCount() - { - var sheet = CreateSheet(3); - - sheet.RegisterRowError(1, "bad image"); - sheet.RegisterRowError(2, "bad image"); - - Assert.AreEqual(2, sheet.ErrorCount); - } - - private static JobSheet CreateSheet(int rowCount) - { - var worksheet = new TestSheet("SheetA", rowCount); - return new JobSheet( - "group", - worksheet, - "output.pptx", - [], - []); - } -} \ No newline at end of file diff --git a/backend/tests/SlideGenerator.Tests/Domain/PauseSignalTests.cs b/backend/tests/SlideGenerator.Tests/Domain/PauseSignalTests.cs deleted file mode 100644 index b7206c95..00000000 --- a/backend/tests/SlideGenerator.Tests/Domain/PauseSignalTests.cs +++ /dev/null @@ -1,33 +0,0 @@ -using SlideGenerator.Domain.Features.Jobs.Components; - -namespace SlideGenerator.Tests.Domain; - -[TestClass] -public sealed class PauseSignalTests -{ - [TestMethod] - public async Task WaitIfPausedAsync_WhenPaused_ThrowsOperationCanceled() - { - var signal = new PauseSignal(); - signal.Pause(); - - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); - try - { - await signal.WaitIfPausedAsync(cts.Token); - Assert.Fail("Expected OperationCanceledException when paused."); - } - catch (OperationCanceledException) - { - } - } - - [TestMethod] - public async Task WaitIfPausedAsync_ReturnsImmediatelyWhenNotPaused() - { - var signal = new PauseSignal(); - - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); - await signal.WaitIfPausedAsync(cts.Token); - } -} \ No newline at end of file diff --git a/backend/tests/SlideGenerator.Tests/Helpers/ConfigTestHelper.cs b/backend/tests/SlideGenerator.Tests/Helpers/ConfigTestHelper.cs deleted file mode 100644 index 9119211a..00000000 --- a/backend/tests/SlideGenerator.Tests/Helpers/ConfigTestHelper.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System.Reflection; -using SlideGenerator.Application.Features.Configs; -using SlideGenerator.Domain.Configs; - -namespace SlideGenerator.Tests.Helpers; - -internal static class ConfigTestHelper -{ - public static Config GetConfig() - { - return ConfigHolder.Value; - } - - public static void SetConfig(Config config) - { - var property = typeof(ConfigHolder).GetProperty("Value", - BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic); - if (property == null) - throw new InvalidOperationException("ConfigHolder.Value property not found."); - property.SetValue(null, config); - } -} \ No newline at end of file diff --git a/backend/tests/SlideGenerator.Tests/Helpers/FakeJobStateStore.cs b/backend/tests/SlideGenerator.Tests/Helpers/FakeJobStateStore.cs deleted file mode 100644 index ef98d20c..00000000 --- a/backend/tests/SlideGenerator.Tests/Helpers/FakeJobStateStore.cs +++ /dev/null @@ -1,108 +0,0 @@ -using SlideGenerator.Domain.Features.Jobs.Enums; -using SlideGenerator.Domain.Features.Jobs.Interfaces; -using SlideGenerator.Domain.Features.Jobs.States; - -namespace SlideGenerator.Tests.Helpers; - -internal sealed class FakeJobStateStore : IJobStateStore -{ - private readonly Dictionary _groups = new(); - private readonly Dictionary> _logs = new(); - private readonly Dictionary _sheets = new(); - - public Task SaveGroupAsync(GroupJobState state, CancellationToken cancellationToken) - { - _groups[state.Id] = state; - return Task.CompletedTask; - } - - public Task SaveSheetAsync(SheetJobState state, CancellationToken cancellationToken) - { - _sheets[state.Id] = state; - return Task.CompletedTask; - } - - public Task GetGroupAsync(string groupId, CancellationToken cancellationToken) - { - _groups.TryGetValue(groupId, out var state); - return Task.FromResult(state); - } - - public Task GetSheetAsync(string sheetId, CancellationToken cancellationToken) - { - _sheets.TryGetValue(sheetId, out var state); - return Task.FromResult(state); - } - - public Task> GetActiveGroupsAsync(CancellationToken cancellationToken) - { - var result = _groups.Values.Where(g => IsActive(g.Status)).ToList(); - return Task.FromResult>(result); - } - - public Task> GetAllGroupsAsync(CancellationToken cancellationToken) - { - return Task.FromResult>(_groups.Values.ToList()); - } - - public Task AppendJobLogAsync(JobLogEntry entry, CancellationToken cancellationToken) - { - return AppendJobLogsAsync([entry], cancellationToken); - } - - public Task AppendJobLogsAsync(IReadOnlyCollection entries, CancellationToken cancellationToken) - { - if (entries.Count == 0) - return Task.CompletedTask; - - foreach (var entry in entries) - { - if (!_logs.TryGetValue(entry.JobId, out var list)) - { - list = new List(); - _logs[entry.JobId] = list; - } - - list.Add(entry); - } - - return Task.CompletedTask; - } - - public Task> GetJobLogsAsync(string jobId, CancellationToken cancellationToken) - { - return Task.FromResult>( - _logs.TryGetValue(jobId, out var list) ? list : []); - } - - public Task> GetSheetsByGroupAsync(string groupId, - CancellationToken cancellationToken) - { - var result = _sheets.Values.Where(s => s.GroupId == groupId).ToList(); - return Task.FromResult>(result); - } - - public Task RemoveGroupAsync(string groupId, CancellationToken cancellationToken) - { - _groups.Remove(groupId); - foreach (var sheetId in _sheets.Values.Where(s => s.GroupId == groupId).Select(s => s.Id)) - { - _sheets.Remove(sheetId); - _logs.Remove(sheetId); - } - - return Task.CompletedTask; - } - - public Task RemoveSheetAsync(string sheetId, CancellationToken cancellationToken) - { - _sheets.Remove(sheetId); - _logs.Remove(sheetId); - return Task.CompletedTask; - } - - private static bool IsActive(GroupStatus status) - { - return status is GroupStatus.Pending or GroupStatus.Running or GroupStatus.Paused; - } -} \ No newline at end of file diff --git a/backend/tests/SlideGenerator.Tests/Helpers/FakeServices.cs b/backend/tests/SlideGenerator.Tests/Helpers/FakeServices.cs deleted file mode 100644 index 34190324..00000000 --- a/backend/tests/SlideGenerator.Tests/Helpers/FakeServices.cs +++ /dev/null @@ -1,424 +0,0 @@ -using System.Drawing; -using SlideGenerator.Application.Common.Utilities; -using SlideGenerator.Application.Features.Images; -using SlideGenerator.Application.Features.Jobs.Contracts; -using SlideGenerator.Application.Features.Jobs.Contracts.Collections; -using SlideGenerator.Application.Features.Jobs.DTOs.Requests; -using SlideGenerator.Application.Features.Sheets; -using SlideGenerator.Application.Features.Slides; -using SlideGenerator.Domain.Features.Images.Enums; -using SlideGenerator.Domain.Features.Jobs.Entities; -using SlideGenerator.Domain.Features.Jobs.Enums; -using SlideGenerator.Domain.Features.Jobs.Interfaces; -using SlideGenerator.Domain.Features.Sheets.Interfaces; -using SlideGenerator.Domain.Features.Slides; - -namespace SlideGenerator.Tests.Helpers; - -internal sealed class FakeSheetService : ISheetService -{ - private readonly Dictionary _workbooks = new(); - - public FakeSheetService(ISheetBook workbook) - { - _workbooks[workbook.FilePath] = workbook; - } - - public ISheetBook OpenFile(string filePath) - { - if (_workbooks.TryGetValue(filePath, out var book)) - return book; - - var sheet = new TestSheet("Sheet1", 1); - var created = new TestSheetBook(filePath, sheet); - _workbooks[filePath] = created; - return created; - } - - public IReadOnlyDictionary GetSheetsInfo(ISheetBook group) - { - return group.GetSheetsInfo(); - } - - public IReadOnlyList GetHeaders(ISheetBook group, string tableName) - { - return group.Worksheets.TryGetValue(tableName, out var sheet) - ? sheet.Headers - : []; - } - - public Dictionary GetRow(ISheetBook group, string tableName, int rowNumber) - { - return group.Worksheets.TryGetValue(tableName, out var sheet) - ? sheet.GetRow(rowNumber) - : new Dictionary(); - } -} - -internal sealed class FakeSlideTemplateManager : ISlideTemplateManager -{ - private readonly Dictionary _templates = new(); - - public FakeSlideTemplateManager(ITemplatePresentation template) - { - _templates[template.FilePath] = template; - } - - public bool AddTemplate(string filepath) - { - if (_templates.ContainsKey(filepath)) - return false; - _templates[filepath] = new TestTemplatePresentation(filepath); - return true; - } - - public bool RemoveTemplate(string filepath) - { - return _templates.Remove(filepath); - } - - public ITemplatePresentation GetTemplate(string filepath) - { - return _templates[filepath]; - } -} - -internal sealed class FakeActiveJobCollection : IActiveJobCollection -{ - private readonly Dictionary _groups = new(); - private readonly Dictionary _sheets = new(); - - public void StartGroup(string groupId) - { - if (_groups.TryGetValue(groupId, out var group)) - group.SetStatus(GroupStatus.Running); - } - - public void PauseGroup(string groupId) - { - if (!_groups.TryGetValue(groupId, out var group)) return; - foreach (var sheet in group.InternalJobs.Values) - sheet.SetStatus(SheetJobStatus.Paused); - group.SetStatus(GroupStatus.Paused); - } - - public void ResumeGroup(string groupId) - { - if (!_groups.TryGetValue(groupId, out var group)) return; - foreach (var sheet in group.InternalJobs.Values.Where(s => s.Status == SheetJobStatus.Paused)) - sheet.SetStatus(SheetJobStatus.Running); - group.SetStatus(GroupStatus.Running); - } - - public void CancelGroup(string groupId) - { - if (!_groups.TryGetValue(groupId, out var group)) return; - foreach (var sheet in group.InternalJobs.Values) - sheet.SetStatus(SheetJobStatus.Cancelled); - group.SetStatus(GroupStatus.Cancelled); - } - - public void CancelAndRemoveGroup(string groupId) - { - if (!_groups.TryGetValue(groupId, out var group)) return; - foreach (var sheet in group.InternalJobs.Values) - _sheets.Remove(sheet.Id); - _groups.Remove(groupId); - } - - public void PauseSheet(string sheetId) - { - if (_sheets.TryGetValue(sheetId, out var sheet)) - sheet.SetStatus(SheetJobStatus.Paused); - } - - public void ResumeSheet(string sheetId) - { - if (_sheets.TryGetValue(sheetId, out var sheet)) - sheet.SetStatus(SheetJobStatus.Running); - } - - public void CancelSheet(string sheetId) - { - if (_sheets.TryGetValue(sheetId, out var sheet)) - sheet.SetStatus(SheetJobStatus.Cancelled); - } - - public void CancelAndRemoveSheet(string sheetId) - { - if (_sheets.Remove(sheetId, out var sheet)) - if (_groups.TryGetValue(sheet.GroupId, out var group)) - group.RemoveJob(sheet.Id); - } - - public void PauseAll() - { - foreach (var group in _groups.Values) - PauseGroup(group.Id); - } - - public void ResumeAll() - { - foreach (var group in _groups.Values) - ResumeGroup(group.Id); - } - - public void CancelAll() - { - foreach (var group in _groups.Values) - CancelGroup(group.Id); - } - - public bool HasActiveJobs => _groups.Values.Any(g => - g.Status is GroupStatus.Pending or GroupStatus.Running or GroupStatus.Paused); - - public IReadOnlyDictionary GetRunningGroups() - { - return _groups.Where(kv => kv.Value.Status == GroupStatus.Running) - .ToDictionary(kv => kv.Key, kv => (IJobGroup)kv.Value); - } - - public IReadOnlyDictionary GetPausedGroups() - { - return _groups.Where(kv => kv.Value.Status == GroupStatus.Paused) - .ToDictionary(kv => kv.Key, kv => (IJobGroup)kv.Value); - } - - public IReadOnlyDictionary GetPendingGroups() - { - return _groups.Where(kv => kv.Value.Status == GroupStatus.Pending) - .ToDictionary(kv => kv.Key, kv => (IJobGroup)kv.Value); - } - - public IJobGroup? GetGroup(string groupId) - { - return _groups.GetValueOrDefault(groupId); - } - - public IReadOnlyDictionary GetAllGroups() - { - return _groups.ToDictionary(kv => kv.Key, kv => (IJobGroup)kv.Value); - } - - public IEnumerable EnumerateGroups() - { - return _groups.Values; - } - - public int GroupCount => _groups.Count; - - public IJobSheet? GetSheet(string sheetId) - { - return _sheets.GetValueOrDefault(sheetId); - } - - public IReadOnlyDictionary GetAllSheets() - { - return _sheets.ToDictionary(kv => kv.Key, kv => (IJobSheet)kv.Value); - } - - public IEnumerable EnumerateSheets() - { - return _sheets.Values; - } - - public int SheetCount => _sheets.Count; - - public bool ContainsGroup(string groupId) - { - return _groups.ContainsKey(groupId); - } - - public bool ContainsSheet(string sheetId) - { - return _sheets.ContainsKey(sheetId); - } - - public bool IsEmpty => _groups.Count == 0; - - public IJobGroup? GetGroupByOutputPath(string outputFolderPath) - { - var normalized = OutputPathUtils.NormalizeOutputFolderPath(outputFolderPath); - return _groups.Values.FirstOrDefault(group => - string.Equals(group.OutputFolder.FullName, normalized, StringComparison.OrdinalIgnoreCase)); - } - - public IJobGroup CreateGroup(JobCreate request) - { - var workbook = CreateWorkbook(); - var template = new TestTemplatePresentation(request.TemplatePath); - var outputRoot = string.IsNullOrWhiteSpace(request.OutputPath) - ? Path.GetTempPath() - : request.OutputPath; - var fullOutputPath = Path.GetFullPath(outputRoot); - var outputFolderPath = OutputPathUtils.NormalizeOutputFolderPath(fullOutputPath); - var outputFolder = new DirectoryInfo(outputFolderPath); - var group = new JobGroup(workbook, template, outputFolder, [], []); - - string[] sheetNames; - if (request.JobType == JobType.Sheet) - { - if (string.IsNullOrWhiteSpace(request.SheetName)) - throw new InvalidOperationException("SheetName is required for sheet jobs."); - sheetNames = [request.SheetName]; - } - else - { - sheetNames = request.SheetNames?.Length > 0 - ? request.SheetNames - : workbook.Worksheets.Keys.ToArray(); - } - - var outputOverrides = new Dictionary(StringComparer.OrdinalIgnoreCase); - if (HasPptxExtension(fullOutputPath) && sheetNames.Length == 1) - outputOverrides[sheetNames[0]] = fullOutputPath; - - foreach (var sheetName in sheetNames) - { - if (!workbook.Worksheets.ContainsKey(sheetName)) - continue; - - var outputPath = outputOverrides.TryGetValue(sheetName, out var overridePath) - ? overridePath - : Path.Combine(outputFolder.FullName, $"{sheetName}.pptx"); - var sheet = group.AddJob(sheetName, outputPath); - _sheets[sheet.Id] = sheet; - } - - _groups[group.Id] = group; - return group; - } - - private static ISheetBook CreateWorkbook() - { - var sheet1 = new TestSheet("Sheet1", 3); - var sheet2 = new TestSheet("Sheet2", 2); - return new TestSheetBook("book.xlsx", sheet1, sheet2); - } - - private static bool HasPptxExtension(string path) - { - return string.Equals(Path.GetExtension(path), ".pptx", StringComparison.OrdinalIgnoreCase); - } -} - -internal sealed class FakeCompletedJobCollection : ICompletedJobCollection -{ - public IJobGroup? GetGroup(string groupId) - { - return null; - } - - public IReadOnlyDictionary GetAllGroups() - { - return new Dictionary(); - } - - public IEnumerable EnumerateGroups() - { - return Array.Empty(); - } - - public int GroupCount => 0; - - public IJobSheet? GetSheet(string sheetId) - { - return null; - } - - public IReadOnlyDictionary GetAllSheets() - { - return new Dictionary(); - } - - public IEnumerable EnumerateSheets() - { - return Array.Empty(); - } - - public int SheetCount => 0; - - public bool ContainsGroup(string groupId) - { - return false; - } - - public bool ContainsSheet(string sheetId) - { - return false; - } - - public bool IsEmpty => true; - - public bool RemoveGroup(string groupId) - { - return false; - } - - public bool RemoveSheet(string sheetId) - { - return false; - } - - public void ClearAll() - { - } - - public IReadOnlyDictionary GetSuccessfulGroups() - { - return new Dictionary(); - } - - public IReadOnlyDictionary GetFailedGroups() - { - return new Dictionary(); - } - - public IReadOnlyDictionary GetCancelledGroups() - { - return new Dictionary(); - } -} - -internal sealed class FakeJobManager(IActiveJobCollection active) : IJobManager -{ - public IActiveJobCollection Active { get; } = active; - public ICompletedJobCollection Completed { get; } = new FakeCompletedJobCollection(); - - public IJobGroup? GetGroup(string groupId) - { - return Active.GetGroup(groupId); - } - - public IJobSheet? GetSheet(string sheetId) - { - return Active.GetSheet(sheetId); - } - - public IReadOnlyDictionary GetAllGroups() - { - return Active.GetAllGroups(); - } -} - -internal sealed class FakeImageService : IImageService -{ - public bool IsFaceModelAvailable { get; private set; } - - public Task CropImageAsync(string filePath, Size size, ImageRoiType roiType, ImageCropType cropType) - { - return Task.FromResult(Array.Empty()); - } - - public Task InitFaceModelAsync() - { - IsFaceModelAvailable = true; - return Task.FromResult(true); - } - - public Task DeInitFaceModelAsync() - { - IsFaceModelAvailable = false; - return Task.FromResult(true); - } -} \ No newline at end of file diff --git a/backend/tests/SlideGenerator.Tests/Helpers/JsonHelper.cs b/backend/tests/SlideGenerator.Tests/Helpers/JsonHelper.cs deleted file mode 100644 index a7591be8..00000000 --- a/backend/tests/SlideGenerator.Tests/Helpers/JsonHelper.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System.Text.Json; - -namespace SlideGenerator.Tests.Helpers; - -internal static class JsonHelper -{ - public static JsonElement Parse(string json) - { - using var document = JsonDocument.Parse(json); - return document.RootElement.Clone(); - } -} \ No newline at end of file diff --git a/backend/tests/SlideGenerator.Tests/Helpers/SignalRTestDoubles.cs b/backend/tests/SlideGenerator.Tests/Helpers/SignalRTestDoubles.cs deleted file mode 100644 index 35b227bc..00000000 --- a/backend/tests/SlideGenerator.Tests/Helpers/SignalRTestDoubles.cs +++ /dev/null @@ -1,140 +0,0 @@ -using System.Security.Claims; -using Microsoft.AspNetCore.Http.Features; -using Microsoft.AspNetCore.SignalR; - -namespace SlideGenerator.Tests.Helpers; - -internal sealed class CaptureClientProxy : IClientProxy -{ - public string? Method { get; private set; } - public object?[]? Args { get; private set; } - - public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default) - { - Method = method; - Args = args; - return Task.CompletedTask; - } - - public T? GetPayload() - { - if (Args == null || Args.Length == 0) - return default; - return (T)Args[0]!; - } -} - -internal sealed class TestHubCallerClients(IClientProxy caller) : IHubCallerClients -{ - public IClientProxy Caller { get; } = caller; - public IClientProxy Others => throw new NotSupportedException(); - - public IClientProxy OthersInGroup(string groupName) - { - throw new NotSupportedException(); - } - - public IClientProxy All => throw new NotSupportedException(); - - public IClientProxy AllExcept(IReadOnlyList excludedConnectionIds) - { - throw new NotSupportedException(); - } - - public IClientProxy Client(string connectionId) - { - throw new NotSupportedException(); - } - - public IClientProxy Clients(IReadOnlyList connectionIds) - { - throw new NotSupportedException(); - } - - public IClientProxy Group(string groupName) - { - throw new NotSupportedException(); - } - - public IClientProxy GroupExcept(string groupName, IReadOnlyList excludedConnectionIds) - { - throw new NotSupportedException(); - } - - public IClientProxy Groups(IReadOnlyList groupNames) - { - throw new NotSupportedException(); - } - - public IClientProxy User(string userId) - { - throw new NotSupportedException(); - } - - public IClientProxy Users(IReadOnlyList userIds) - { - throw new NotSupportedException(); - } -} - -internal sealed class TestHubCallerContext(string connectionId) : HubCallerContext -{ - public override string ConnectionId { get; } = connectionId; - public override string? UserIdentifier { get; } - public override ClaimsPrincipal? User { get; } - public override IDictionary Items { get; } = new Dictionary(); - - public override IFeatureCollection Features { get; } = new FeatureCollection(); - public override CancellationToken ConnectionAborted { get; } - - public override void Abort() - { - } -} - -internal sealed class TestGroupManager : IGroupManager -{ - public List<(string ConnectionId, string GroupName)> Added { get; } = []; - public List<(string ConnectionId, string GroupName)> Removed { get; } = []; - - public Task AddToGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default) - { - Added.Add((connectionId, groupName)); - return Task.CompletedTask; - } - - public Task RemoveFromGroupAsync(string connectionId, string groupName, - CancellationToken cancellationToken = default) - { - Removed.Add((connectionId, groupName)); - return Task.CompletedTask; - } -} - -internal static class HubTestHelper -{ - public static CaptureClientProxy Attach( - Hub hub, - string connectionId, - TestGroupManager? groupManager = null) - { - var proxy = new CaptureClientProxy(); - var clients = new TestHubCallerClients(proxy); - var context = new TestHubCallerContext(connectionId); - - SetHubProperty(hub, "Clients", clients); - SetHubProperty(hub, "Context", context); - if (groupManager != null) - SetHubProperty(hub, "Groups", groupManager); - - return proxy; - } - - private static void SetHubProperty(object target, string propertyName, object value) - { - var property = target.GetType().BaseType?.GetProperty(propertyName); - if (property == null) - throw new InvalidOperationException($"Hub property '{propertyName}' not found."); - property.SetValue(target, value); - } -} \ No newline at end of file diff --git a/backend/tests/SlideGenerator.Tests/Helpers/TestFixtures.cs b/backend/tests/SlideGenerator.Tests/Helpers/TestFixtures.cs deleted file mode 100644 index 8a4ad683..00000000 --- a/backend/tests/SlideGenerator.Tests/Helpers/TestFixtures.cs +++ /dev/null @@ -1,91 +0,0 @@ -using SlideGenerator.Domain.Features.Sheets.Interfaces; -using SlideGenerator.Domain.Features.Slides; -using SlideGenerator.Domain.Features.Slides.Components; - -namespace SlideGenerator.Tests.Helpers; - -internal sealed class TestSheet( - string name, - int rowCount, - IReadOnlyList headers, - List>? rows) - : ISheet -{ - private readonly List> _rows = rows ?? []; - - public TestSheet(string name, int rowCount) - : this(name, rowCount, [], null) - { - } - - public string Name { get; } = name; - public IReadOnlyList Headers { get; } = headers; - public int RowCount { get; } = rowCount; - - public Dictionary GetRow(int rowNumber) - { - var index = rowNumber - 1; - if (index < 0 || index >= _rows.Count) - return new Dictionary(); - return new Dictionary(_rows[index]); - } - - public List> GetAllRows() - { - return _rows.Select(row => new Dictionary(row)).ToList(); - } -} - -internal sealed class TestSheetBook(string filePath, params ISheet[] sheets) : ISheetBook -{ - public string FilePath { get; } = filePath; - public string? Name { get; } = Path.GetFileNameWithoutExtension(filePath); - - public IReadOnlyDictionary Worksheets { get; } = - sheets.ToDictionary(sheet => sheet.Name, sheet => sheet); - - public IReadOnlyDictionary GetSheetsInfo() - { - return Worksheets.ToDictionary(kv => kv.Key, kv => kv.Value.RowCount); - } - - public void Dispose() - { - } -} - -internal sealed class TestTemplatePresentation( - string filePath, - int slideCount = 1, - IReadOnlyList? shapes = null, - Dictionary? imageShapes = null, - IReadOnlyCollection? placeholders = null) - : ITemplatePresentation -{ - private readonly Dictionary _imageShapes = imageShapes ?? new Dictionary(); - private readonly IReadOnlyCollection _placeholders = placeholders ?? Array.Empty(); - private readonly IReadOnlyList _shapes = shapes ?? []; - - public string FilePath { get; } = filePath; - public int SlideCount { get; } = slideCount; - - public Dictionary GetAllImageShapes() - { - return new Dictionary(_imageShapes); - } - - public IReadOnlyList GetAllShapes() - { - return _shapes; - } - - public IReadOnlyCollection GetAllTextPlaceholders() - { - return _placeholders; - } - - public void Dispose() - { - // Nothing to dispose - } -} \ No newline at end of file diff --git a/backend/tests/SlideGenerator.Tests/Infrastructure/ConfigLoaderTests.cs b/backend/tests/SlideGenerator.Tests/Infrastructure/ConfigLoaderTests.cs deleted file mode 100644 index 98752cfd..00000000 --- a/backend/tests/SlideGenerator.Tests/Infrastructure/ConfigLoaderTests.cs +++ /dev/null @@ -1,61 +0,0 @@ -using SlideGenerator.Domain.Configs; -using SlideGenerator.Infrastructure.Features.Configs; - -namespace SlideGenerator.Tests.Infrastructure; - -[TestClass] -[DoNotParallelize] -public sealed class ConfigLoaderTests -{ - [TestMethod] - public void Load_ReturnsNullWhenMissing() - { - var originalDir = Environment.CurrentDirectory; - var tempDir = Path.Combine(Path.GetTempPath(), "SlideGeneratorTests", Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(tempDir); - - try - { - Environment.CurrentDirectory = tempDir; - var @lock = new Lock(); - - var loaded = ConfigLoader.Load(@lock); - - Assert.IsNull(loaded); - } - finally - { - Environment.CurrentDirectory = originalDir; - Directory.Delete(tempDir, true); - } - } - - [TestMethod] - public void SaveAndLoad_RoundTripsJobConfig() - { - var originalDir = Environment.CurrentDirectory; - var tempDir = Path.Combine(Path.GetTempPath(), "SlideGeneratorTests", Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(tempDir); - - try - { - Environment.CurrentDirectory = tempDir; - var @lock = new Lock(); - var config = new Config - { - Job = new Config.JobConfig { MaxConcurrentJobs = 7 } - }; - - ConfigLoader.Save(config, @lock); - var loaded = ConfigLoader.Load(@lock); - - Assert.IsNotNull(loaded); - Assert.AreEqual(7, loaded.Job.MaxConcurrentJobs); - } - finally - { - Environment.CurrentDirectory = originalDir; - Directory.Delete(tempDir, true); - } - } -} \ No newline at end of file diff --git a/backend/tests/SlideGenerator.Tests/Infrastructure/ResizingFaceDetectorModelTests.cs b/backend/tests/SlideGenerator.Tests/Infrastructure/ResizingFaceDetectorModelTests.cs deleted file mode 100644 index 16fcf5f0..00000000 --- a/backend/tests/SlideGenerator.Tests/Infrastructure/ResizingFaceDetectorModelTests.cs +++ /dev/null @@ -1,166 +0,0 @@ -using System.Drawing; -using Emgu.CV; -using Emgu.CV.CvEnum; -using Emgu.CV.Structure; -using Emgu.CV.Util; -using Microsoft.Extensions.Logging; -using SlideGenerator.Framework.Image.Modules.FaceDetection.Models; -using SlideGenerator.Infrastructure.Features.Images.Services; -using CoreImage = SlideGenerator.Framework.Image.Models.Image; -using LogLevel = Microsoft.Extensions.Logging.LogLevel; - -namespace SlideGenerator.Tests.Infrastructure; - -[TestClass] -public class ResizingFaceDetectorModelTests -{ - private FakeFaceDetectorModel _fakeInner = null!; - private FakeLogger _fakeLogger = null!; - - [TestInitialize] - public void Setup() - { - _fakeInner = new FakeFaceDetectorModel(); - _fakeLogger = new FakeLogger(); - } - - [TestCleanup] - public void Cleanup() - { - _fakeInner.Dispose(); - } - - private CoreImage CreateTestImage(int width, int height) - { - // Create a simple image in memory - var mat = new Mat(height, width, DepthType.Cv8U, 3); - mat.SetTo(new MCvScalar(255, 255, 255)); // White image - - // Use reflection or a helper to create CoreImage since it doesn't have a public constructor taking Mat - // Actually CoreImage has a constructor taking byte[]. - // But to avoid encoding/decoding overhead in test, let's use the reflection trick used in the main code - // OR better: Create a valid PNG byte array from the Mat and use the public constructor. - - // Let's try the public constructor with bytes to be safe and "real". - // To avoid dependency on ImageMagick in test setup if possible, let's just use the Mat directly - // if we can inject it. But CoreImage.Mat is internal set. - - // We will rely on the fact that we can construct it via file or bytes. - // Let's use the byte[] constructor. - using var vector = new VectorOfByte(); - CvInvoke.Imencode(".png", mat, vector); - return new CoreImage(vector.ToArray()); - } - - [TestMethod] - public async Task DetectAsync_WithZeroMaxDim_ShouldNotResize() - { - // Arrange - var model = new ResizingFaceDetectorModel(_fakeInner, () => 0, _fakeLogger); - using var image = CreateTestImage(2000, 2000); - - // Act - await model.DetectAsync(image, 0.5f); - - // Assert - Assert.AreEqual(2000, _fakeInner.LastDetectedImageSize.Width); - Assert.AreEqual(2000, _fakeInner.LastDetectedImageSize.Height); - } - - [TestMethod] - public async Task DetectAsync_WithSmallImage_ShouldNotResize() - { - // Arrange - var model = new ResizingFaceDetectorModel(_fakeInner, () => 1500, _fakeLogger); - using var image = CreateTestImage(1000, 1000); - - // Act - await model.DetectAsync(image, 0.5f); - - // Assert - Assert.AreEqual(1000, _fakeInner.LastDetectedImageSize.Width); - Assert.AreEqual(1000, _fakeInner.LastDetectedImageSize.Height); - } - - [TestMethod] - public async Task DetectAsync_WithLargeImage_ShouldResizeAndScaleResults() - { - // Arrange - var model = new ResizingFaceDetectorModel(_fakeInner, () => 500, _fakeLogger); - using var image = CreateTestImage(1000, 1000); // 1000x1000 -> Should resize to 500x500 (Scale 0.5) - - // Setup fake result on the *resized* image - // The inner model sees a 500x500 image. - // Let's say it finds a face at (50, 50) with size (100, 100). - // The original face should be at (100, 100) with size (200, 200). - _fakeInner.FacesToReturn = new List - { - new(new Rectangle(50, 50, 100, 100), 0.9f) - }; - - // Act - var results = await model.DetectAsync(image, 0.5f); - - // Assert - Assert.AreEqual(500, _fakeInner.LastDetectedImageSize.Width); - Assert.AreEqual(500, _fakeInner.LastDetectedImageSize.Height); - - Assert.HasCount(1, results); - var face = results[0]; - - // Check scaled coordinates - // Expected: 50 / 0.5 = 100 - Assert.AreEqual(100, face.Rect.X); - Assert.AreEqual(100, face.Rect.Y); - Assert.AreEqual(200, face.Rect.Width); - Assert.AreEqual(200, face.Rect.Height); - } -} - -// Fake classes -public class FakeFaceDetectorModel : FaceDetectorModel -{ - public Size LastDetectedImageSize { get; private set; } - public List FacesToReturn { get; set; } = new(); - - public override bool IsModelAvailable => true; - - public override void Dispose() - { - } - - public override Task InitAsync() - { - return Task.FromResult(true); - } - - public override Task DeInitAsync() - { - return Task.FromResult(true); - } - - public override Task> DetectAsync(CoreImage image, float minScore) - { - LastDetectedImageSize = image.Size; - return Task.FromResult(FacesToReturn); - } -} - -public class FakeLogger : ILogger -{ - public IDisposable? BeginScope(TState state) where TState : notnull - { - return null; - } - - public bool IsEnabled(LogLevel logLevel) - { - return true; - } - - public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, - Func formatter) - { - // No-op - } -} \ No newline at end of file diff --git a/backend/tests/SlideGenerator.Tests/MSTestSettings.cs b/backend/tests/SlideGenerator.Tests/MSTestSettings.cs deleted file mode 100644 index 8b7de71c..00000000 --- a/backend/tests/SlideGenerator.Tests/MSTestSettings.cs +++ /dev/null @@ -1 +0,0 @@ -[assembly: Parallelize(Scope = ExecutionScope.MethodLevel)] \ No newline at end of file diff --git a/backend/tests/SlideGenerator.Tests/Presentation/ConfigHubTests.cs b/backend/tests/SlideGenerator.Tests/Presentation/ConfigHubTests.cs deleted file mode 100644 index 3d84bd53..00000000 --- a/backend/tests/SlideGenerator.Tests/Presentation/ConfigHubTests.cs +++ /dev/null @@ -1,137 +0,0 @@ -using Microsoft.Extensions.Logging.Abstractions; -using SlideGenerator.Application.Features.Configs; -using SlideGenerator.Application.Features.Configs.DTOs.Responses.Successes; -using SlideGenerator.Domain.Configs; -using SlideGenerator.Infrastructure.Features.Configs; -using SlideGenerator.Presentation.Features.Configs; -using SlideGenerator.Tests.Helpers; - -namespace SlideGenerator.Tests.Presentation; - -[TestClass] -[DoNotParallelize] -public sealed class ConfigHubTests -{ - [TestMethod] - public async Task ProcessRequest_Get_ReturnsConfig() - { - var hub = CreateHub(out var proxy); - var message = JsonHelper.Parse("{\"type\":\"get\"}"); - - await hub.ProcessRequest(message); - - var response = proxy.GetPayload(); - Assert.IsNotNull(response); - Assert.AreEqual(ConfigHolder.Value.Job.MaxConcurrentJobs, response.Job.MaxConcurrentJobs); - } - - [TestMethod] - public async Task ProcessRequest_Update_ChangesConfig() - { - var original = ConfigTestHelper.GetConfig(); - var originalDir = Environment.CurrentDirectory; - var tempDir = CreateTempDirectory(); - - try - { - Environment.CurrentDirectory = tempDir; - var hub = CreateHub(out var proxy); - var message = JsonHelper.Parse("{\"type\":\"update\",\"job\":{\"maxConcurrentJobs\":9}}"); - - await hub.ProcessRequest(message); - - var response = proxy.GetPayload(); - Assert.IsNotNull(response); - Assert.AreEqual(9, ConfigHolder.Value.Job.MaxConcurrentJobs); - } - finally - { - ConfigTestHelper.SetConfig(original); - Environment.CurrentDirectory = originalDir; - Directory.Delete(tempDir, true); - } - } - - [TestMethod] - public async Task ProcessRequest_Reload_LoadsFromDisk() - { - var original = ConfigTestHelper.GetConfig(); - var originalDir = Environment.CurrentDirectory; - var tempDir = CreateTempDirectory(); - - try - { - Environment.CurrentDirectory = tempDir; - var @lock = new Lock(); - var saved = new Config - { - Job = new Config.JobConfig { MaxConcurrentJobs = 7 } - }; - ConfigLoader.Save(saved, @lock); - ConfigTestHelper.SetConfig(new Config - { - Job = new Config.JobConfig { MaxConcurrentJobs = 2 } - }); - - var hub = CreateHub(out var proxy); - await hub.ProcessRequest(JsonHelper.Parse("{\"type\":\"reload\"}")); - - var response = proxy.GetPayload(); - Assert.IsNotNull(response); - Assert.AreEqual(7, ConfigHolder.Value.Job.MaxConcurrentJobs); - } - finally - { - ConfigTestHelper.SetConfig(original); - Environment.CurrentDirectory = originalDir; - Directory.Delete(tempDir, true); - } - } - - [TestMethod] - public async Task ProcessRequest_Reset_ResetsDefaults() - { - var original = ConfigTestHelper.GetConfig(); - var originalDir = Environment.CurrentDirectory; - var tempDir = CreateTempDirectory(); - - try - { - Environment.CurrentDirectory = tempDir; - ConfigTestHelper.SetConfig(new Config - { - Job = new Config.JobConfig { MaxConcurrentJobs = 12 } - }); - - var hub = CreateHub(out var proxy); - await hub.ProcessRequest(JsonHelper.Parse("{\"type\":\"reset\"}")); - - var response = proxy.GetPayload(); - Assert.IsNotNull(response); - Assert.AreEqual(5, ConfigHolder.Value.Job.MaxConcurrentJobs); - } - finally - { - ConfigTestHelper.SetConfig(original); - Environment.CurrentDirectory = originalDir; - Directory.Delete(tempDir, true); - } - } - - private static ConfigHub CreateHub(out CaptureClientProxy proxy) - { - var hub = new ConfigHub( - new FakeJobManager(new FakeActiveJobCollection()), - new FakeImageService(), - NullLogger.Instance); - proxy = HubTestHelper.Attach(hub, "conn-1"); - return hub; - } - - private static string CreateTempDirectory() - { - var tempDir = Path.Combine(Path.GetTempPath(), "SlideGeneratorTests", Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(tempDir); - return tempDir; - } -} \ No newline at end of file diff --git a/backend/tests/SlideGenerator.Tests/Presentation/JobHubSubscriptionTests.cs b/backend/tests/SlideGenerator.Tests/Presentation/JobHubSubscriptionTests.cs deleted file mode 100644 index 44f706a5..00000000 --- a/backend/tests/SlideGenerator.Tests/Presentation/JobHubSubscriptionTests.cs +++ /dev/null @@ -1,42 +0,0 @@ -using Microsoft.Extensions.Logging.Abstractions; -using SlideGenerator.Application.Features.Jobs; -using SlideGenerator.Presentation.Features.Jobs; -using SlideGenerator.Tests.Helpers; - -namespace SlideGenerator.Tests.Presentation; - -[TestClass] -public sealed class JobHubSubscriptionTests -{ - [TestMethod] - public async Task SubscribeGroup_AddsConnectionToGroup() - { - var groupManager = new TestGroupManager(); - var hub = new JobHub(new FakeJobManager(new FakeActiveJobCollection()), - new FakeSlideTemplateManager(new TestTemplatePresentation("template.pptx")), - new FakeJobStateStore(), - NullLogger.Instance); - HubTestHelper.Attach(hub, "conn-1", groupManager); - - await hub.SubscribeGroup("group-1"); - - Assert.HasCount(1, groupManager.Added); - Assert.AreEqual(JobSignalRGroups.GroupGroup("group-1"), groupManager.Added[0].GroupName); - } - - [TestMethod] - public async Task SubscribeSheet_AddsConnectionToGroup() - { - var groupManager = new TestGroupManager(); - var hub = new JobHub(new FakeJobManager(new FakeActiveJobCollection()), - new FakeSlideTemplateManager(new TestTemplatePresentation("template.pptx")), - new FakeJobStateStore(), - NullLogger.Instance); - HubTestHelper.Attach(hub, "conn-2", groupManager); - - await hub.SubscribeSheet("sheet-1"); - - Assert.HasCount(1, groupManager.Added); - Assert.AreEqual(JobSignalRGroups.SheetGroup("sheet-1"), groupManager.Added[0].GroupName); - } -} \ No newline at end of file diff --git a/backend/tests/SlideGenerator.Tests/Presentation/JobHubTests.cs b/backend/tests/SlideGenerator.Tests/Presentation/JobHubTests.cs deleted file mode 100644 index 6c862f57..00000000 --- a/backend/tests/SlideGenerator.Tests/Presentation/JobHubTests.cs +++ /dev/null @@ -1,207 +0,0 @@ -using Microsoft.Extensions.Logging.Abstractions; -using SlideGenerator.Application.Features.Jobs.DTOs.Responses.Successes; -using SlideGenerator.Application.Features.Slides.DTOs.Enums; -using SlideGenerator.Application.Features.Slides.DTOs.Responses.Successes; -using SlideGenerator.Domain.Features.Jobs.Enums; -using SlideGenerator.Domain.Features.Slides.Components; -using SlideGenerator.Presentation.Features.Jobs; -using SlideGenerator.Tests.Helpers; - -namespace SlideGenerator.Tests.Presentation; - -[TestClass] -public sealed class JobHubTests -{ - [TestMethod] - public async Task ProcessRequest_ScanShapes_ReturnsShapes() - { - var shapes = new List - { - new(1, "ShapeA", "Shape", true), - new(2, "ShapeB", "Shape", false) - }; - var imageShapes = new Dictionary - { - [1] = new("ShapeA", [0x01, 0x02]) - }; - var template = new TestTemplatePresentation("template.pptx", shapes: shapes, imageShapes: imageShapes); - var templateManager = new FakeSlideTemplateManager(template); - var jobManager = new FakeJobManager(new FakeActiveJobCollection()); - - var hub = new JobHub(jobManager, templateManager, new FakeJobStateStore(), NullLogger.Instance); - var proxy = HubTestHelper.Attach(hub, "conn-1"); - - var message = JsonHelper.Parse("{\"type\":\"scanshapes\",\"filePath\":\"template.pptx\"}"); - await hub.ProcessRequest(message); - - var response = proxy.GetPayload(); - Assert.IsNotNull(response); - Assert.AreEqual("template.pptx", response.FilePath); - Assert.HasCount(2, response.Shapes); - Assert.AreEqual("ShapeA", response.Shapes[0].Name); - Assert.IsFalse(string.IsNullOrWhiteSpace(response.Shapes[0].Data)); - } - - [TestMethod] - public async Task ProcessRequest_ScanPlaceholders_ReturnsPlaceholders() - { - var placeholders = new[] { "{{Name}}", "{{Code}}" }; - var template = new TestTemplatePresentation("template.pptx", placeholders: placeholders); - var templateManager = new FakeSlideTemplateManager(template); - var jobManager = new FakeJobManager(new FakeActiveJobCollection()); - - var hub = new JobHub(jobManager, templateManager, new FakeJobStateStore(), NullLogger.Instance); - var proxy = HubTestHelper.Attach(hub, "conn-1b"); - - var message = JsonHelper.Parse("{\"type\":\"scanplaceholders\",\"filePath\":\"template.pptx\"}"); - await hub.ProcessRequest(message); - - var response = proxy.GetPayload(); - Assert.IsNotNull(response); - CollectionAssert.AreEquivalent(placeholders, response.Placeholders); - } - - [TestMethod] - public async Task ProcessRequest_ScanTemplate_ReturnsShapesAndPlaceholders() - { - var shapes = new List - { - new(10, "CoverImage", "Picture", true) - }; - var imageShapes = new Dictionary - { - [10] = new("CoverImage", [0x10, 0x20]) - }; - var placeholders = new[] { "{{Title}}", "{{Date}}" }; - var template = new TestTemplatePresentation( - "template.pptx", - shapes: shapes, - imageShapes: imageShapes, - placeholders: placeholders); - var templateManager = new FakeSlideTemplateManager(template); - var jobManager = new FakeJobManager(new FakeActiveJobCollection()); - - var hub = new JobHub(jobManager, templateManager, new FakeJobStateStore(), NullLogger.Instance); - var proxy = HubTestHelper.Attach(hub, "conn-1c"); - - var message = JsonHelper.Parse("{\"type\":\"scantemplate\",\"filePath\":\"template.pptx\"}"); - await hub.ProcessRequest(message); - - var response = proxy.GetPayload(); - Assert.IsNotNull(response); - Assert.HasCount(1, response.Shapes); - Assert.AreEqual("CoverImage", response.Shapes[0].Name); - CollectionAssert.AreEquivalent(placeholders, response.Placeholders); - } - - [TestMethod] - public async Task ProcessRequest_JobCreate_Group_ReturnsSummaryAndSheetIds() - { - var hub = CreateHub(out var proxy, out _); - - var json = - "{\"type\":\"jobcreate\",\"jobType\":\"Group\",\"templatePath\":\"template.pptx\",\"spreadsheetPath\":\"book.xlsx\",\"outputPath\":\"C:\\\\out\",\"sheetNames\":[\"Sheet1\"]}"; - await hub.ProcessRequest(JsonHelper.Parse(json)); - - var response = proxy.GetPayload(); - Assert.IsNotNull(response); - Assert.AreEqual(JobType.Group, response.Job.JobType); - Assert.AreEqual(JobState.Processing, response.Job.Status); - Assert.IsNotNull(response.SheetJobIds); - Assert.HasCount(1, response.SheetJobIds); - Assert.AreEqual(Path.GetFullPath("C:\\out"), response.Job.OutputPath); - } - - [TestMethod] - public async Task ProcessRequest_JobCreate_Sheet_ReturnsSheetSummary() - { - var hub = CreateHub(out var proxy, out var jobManager); - - var json = - "{\"type\":\"jobcreate\",\"jobType\":\"Sheet\",\"templatePath\":\"template.pptx\",\"spreadsheetPath\":\"book.xlsx\",\"outputPath\":\"C:\\\\out\",\"sheetName\":\"Sheet2\"}"; - await hub.ProcessRequest(JsonHelper.Parse(json)); - - var response = proxy.GetPayload(); - Assert.IsNotNull(response); - Assert.AreEqual(JobType.Sheet, response.Job.JobType); - Assert.AreEqual("Sheet2", response.Job.SheetName); - Assert.IsNull(response.SheetJobIds); - Assert.IsNotNull(jobManager.GetSheet(response.Job.JobId)); - } - - [TestMethod] - public async Task ProcessRequest_JobQuery_ReturnsDetailWithSheets() - { - var hub = CreateHub(out var proxy, out _); - - var createJson = - "{\"type\":\"jobcreate\",\"jobType\":\"Group\",\"templatePath\":\"template.pptx\",\"spreadsheetPath\":\"book.xlsx\",\"outputPath\":\"C:\\\\out\",\"sheetNames\":[\"Sheet1\"]}"; - await hub.ProcessRequest(JsonHelper.Parse(createJson)); - var created = proxy.GetPayload(); - Assert.IsNotNull(created); - - var queryJson = - $"{{\"type\":\"jobquery\",\"jobId\":\"{created.Job.JobId}\",\"jobType\":\"Group\",\"includeSheets\":true}}"; - await hub.ProcessRequest(JsonHelper.Parse(queryJson)); - - var response = proxy.GetPayload(); - Assert.IsNotNull(response); - Assert.IsNotNull(response.Job); - Assert.AreEqual(created.Job.JobId, response.Job.JobId); - Assert.IsNotNull(response.Job.Sheets); - Assert.HasCount(1, response.Job.Sheets); - } - - [TestMethod] - public async Task ProcessRequest_JobControl_PausesGroup() - { - var hub = CreateHub(out var proxy, out var jobManager); - - var createJson = - "{\"type\":\"jobcreate\",\"jobType\":\"Group\",\"templatePath\":\"template.pptx\",\"spreadsheetPath\":\"book.xlsx\",\"outputPath\":\"C:\\\\out\"}"; - await hub.ProcessRequest(JsonHelper.Parse(createJson)); - var created = proxy.GetPayload(); - Assert.IsNotNull(created); - - var controlJson = - $"{{\"type\":\"jobcontrol\",\"jobId\":\"{created.Job.JobId}\",\"jobType\":\"Group\",\"action\":\"Pause\"}}"; - await hub.ProcessRequest(JsonHelper.Parse(controlJson)); - - var response = proxy.GetPayload(); - Assert.IsNotNull(response); - Assert.AreEqual(ControlAction.Pause, response.Action); - Assert.AreEqual(GroupStatus.Paused, jobManager.GetGroup(created.Job.JobId)!.Status); - } - - [TestMethod] - public async Task ProcessRequest_JobControl_RemoveGroup_RemovesFromActive() - { - var hub = CreateHub(out var proxy, out var jobManager); - - var createJson = - "{\"type\":\"jobcreate\",\"jobType\":\"Group\",\"templatePath\":\"template.pptx\",\"spreadsheetPath\":\"book.xlsx\",\"outputPath\":\"C:\\\\out\"}"; - await hub.ProcessRequest(JsonHelper.Parse(createJson)); - var created = proxy.GetPayload(); - Assert.IsNotNull(created); - - var controlJson = - $"{{\"type\":\"jobcontrol\",\"jobId\":\"{created.Job.JobId}\",\"jobType\":\"Group\",\"action\":\"Remove\"}}"; - await hub.ProcessRequest(JsonHelper.Parse(controlJson)); - - var response = proxy.GetPayload(); - Assert.IsNotNull(response); - Assert.AreEqual(ControlAction.Remove, response.Action); - Assert.IsFalse(jobManager.Active.ContainsGroup(created.Job.JobId)); - } - - private static JobHub CreateHub(out CaptureClientProxy proxy, out FakeJobManager jobManager) - { - var active = new FakeActiveJobCollection(); - jobManager = new FakeJobManager(active); - var templateManager = new FakeSlideTemplateManager(new TestTemplatePresentation("template.pptx")); - - var hub = new JobHub(jobManager, templateManager, new FakeJobStateStore(), NullLogger.Instance); - proxy = HubTestHelper.Attach(hub, "conn-2"); - return hub; - } -} \ No newline at end of file diff --git a/backend/tests/SlideGenerator.Tests/Presentation/SheetHubTests.cs b/backend/tests/SlideGenerator.Tests/Presentation/SheetHubTests.cs deleted file mode 100644 index dc47feb8..00000000 --- a/backend/tests/SlideGenerator.Tests/Presentation/SheetHubTests.cs +++ /dev/null @@ -1,117 +0,0 @@ -using Microsoft.Extensions.Logging.Abstractions; -using SlideGenerator.Application.Features.Sheets.DTOs.Responses.Successes.Workbook; -using SlideGenerator.Application.Features.Sheets.DTOs.Responses.Successes.Worksheet; -using SlideGenerator.Presentation.Features.Sheets; -using SlideGenerator.Tests.Helpers; - -namespace SlideGenerator.Tests.Presentation; - -[TestClass] -public sealed class SheetHubTests -{ - [TestMethod] - public async Task ProcessRequest_OpenFile_ReturnsSuccess() - { - var hub = CreateHub(out var proxy); - await hub.OnConnectedAsync(); - - var message = JsonHelper.Parse("{\"type\":\"openfile\",\"filePath\":\"book.xlsx\"}"); - await hub.ProcessRequest(message); - - var response = proxy.GetPayload(); - Assert.IsNotNull(response); - Assert.AreEqual("book.xlsx", response.FilePath); - } - - [TestMethod] - public async Task ProcessRequest_GetTables_ReturnsSheetInfo() - { - var hub = CreateHub(out var proxy); - await hub.OnConnectedAsync(); - - var message = JsonHelper.Parse("{\"type\":\"gettables\",\"filePath\":\"book.xlsx\"}"); - await hub.ProcessRequest(message); - - var response = proxy.GetPayload(); - Assert.IsNotNull(response); - Assert.HasCount(1, response.Sheets); - Assert.AreEqual(2, response.Sheets["Sheet1"]); - } - - [TestMethod] - public async Task ProcessRequest_GetHeaders_ReturnsHeaders() - { - var hub = CreateHub(out var proxy); - await hub.OnConnectedAsync(); - - var message = JsonHelper.Parse("{\"type\":\"getheaders\",\"filePath\":\"book.xlsx\",\"sheetName\":\"Sheet1\"}"); - await hub.ProcessRequest(message); - - var response = proxy.GetPayload(); - Assert.IsNotNull(response); - Assert.HasCount(2, response.Headers); - Assert.AreEqual("Name", response.Headers[0]); - } - - [TestMethod] - public async Task ProcessRequest_GetRow_ReturnsRowData() - { - var hub = CreateHub(out var proxy); - await hub.OnConnectedAsync(); - - var message = - JsonHelper.Parse( - "{\"type\":\"getrow\",\"filePath\":\"book.xlsx\",\"tableName\":\"Sheet1\",\"rowNumber\":1}"); - await hub.ProcessRequest(message); - - var response = proxy.GetPayload(); - Assert.IsNotNull(response); - Assert.AreEqual("Alice", response.Row["Name"]); - } - - [TestMethod] - public async Task ProcessRequest_GetWorkbookInfo_ReturnsDetails() - { - var hub = CreateHub(out var proxy); - await hub.OnConnectedAsync(); - - var message = JsonHelper.Parse("{\"type\":\"getworkbookinfo\",\"filePath\":\"book.xlsx\"}"); - await hub.ProcessRequest(message); - - var response = proxy.GetPayload(); - Assert.IsNotNull(response); - Assert.HasCount(1, response.Sheets); - Assert.AreEqual("Sheet1", response.Sheets[0].Name); - } - - [TestMethod] - public async Task ProcessRequest_CloseFile_ReturnsSuccess() - { - var hub = CreateHub(out var proxy); - await hub.OnConnectedAsync(); - - var message = JsonHelper.Parse("{\"type\":\"closefile\",\"filePath\":\"book.xlsx\"}"); - await hub.ProcessRequest(message); - - var response = proxy.GetPayload(); - Assert.IsNotNull(response); - Assert.AreEqual("book.xlsx", response.FilePath); - } - - private static SheetHub CreateHub(out CaptureClientProxy proxy) - { - var headers = new List { "Name", "Url" }; - var rows = new List> - { - new() { ["Name"] = "Alice", ["Url"] = "http://a" }, - new() { ["Name"] = "Bob", ["Url"] = "http://b" } - }; - var sheet = new TestSheet("Sheet1", rows.Count, headers, rows); - var workbook = new TestSheetBook("book.xlsx", sheet); - var sheetService = new FakeSheetService(workbook); - - var hub = new SheetHub(sheetService, NullLogger.Instance); - proxy = HubTestHelper.Attach(hub, "conn-1"); - return hub; - } -} \ No newline at end of file diff --git a/backend/tests/SlideGenerator.Tests/SlideGenerator.Tests.csproj b/backend/tests/SlideGenerator.Tests/SlideGenerator.Tests.csproj deleted file mode 100644 index 48f152d5..00000000 --- a/backend/tests/SlideGenerator.Tests/SlideGenerator.Tests.csproj +++ /dev/null @@ -1,25 +0,0 @@ - - - - net10.0 - latest - enable - enable - - - - - - - - - - - - - - - - - - diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 00000000..9e370bb2 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,4 @@ +VITE_BACKEND_URL=http://127.0.0.1:65500 +VITE_SHEET_HUB_PATH=/hubs/sheet +VITE_JOB_HUB_PATH=/hubs/job +VITE_CONFIG_HUB_PATH=/hubs/config diff --git a/frontend/README.md b/frontend/README.md index 4c94e730..a932ab37 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -34,6 +34,19 @@ npm run dev > **Note:** By default, Electron will attempt to launch the backend binary. To disable this behavior (e.g., when debugging the backend separately in Visual Studio), set the environment variable: `SLIDEGEN_DISABLE_BACKEND=1`. +### Configure Backend Endpoint + +You can override backend endpoints via Vite env variables: + +```bash +VITE_BACKEND_URL=http://127.0.0.1:65500 +VITE_SHEET_RPC_CHANNEL=sheets +VITE_JOB_RPC_CHANNEL=jobs +VITE_CONFIG_RPC_CHANNEL=config +``` + +Create `frontend/.env.local` for local development overrides. + ## Project Structure The codebase is organized by feature: diff --git a/frontend/docs/en/development.md b/frontend/docs/en/development.md index d93498f8..b2fffa08 100644 --- a/frontend/docs/en/development.md +++ b/frontend/docs/en/development.md @@ -43,7 +43,7 @@ src/ │ ├── components/ # Atomic UI components (Buttons, Inputs) │ ├── contexts/ # React Contexts (JobContext, AppContext) │ ├── hooks/ # Custom React Hooks -│ ├── services/ # API & SignalR clients +│ ├── services/ # API & RPC clients │ └── styles/ # Global SCSS & Variables └── assets/ # Static assets (Images, Fonts) ``` diff --git a/frontend/docs/en/overview.md b/frontend/docs/en/overview.md index 919da708..c30e6640 100644 --- a/frontend/docs/en/overview.md +++ b/frontend/docs/en/overview.md @@ -9,7 +9,7 @@ The SlideGenerator Frontend is a specialized desktop application designed to: 2. Offer real-time monitoring of background processes. 3. Manage local application settings and themes. -**Key Principle:** The Frontend is "Thin". It holds minimal business logic. The Backend is the source of truth for all job states. The UI simply reflects the state received via SignalR. +**Key Principle:** The Frontend is "Thin". It holds minimal business logic. The Backend is the source of truth for all job states. The UI simply reflects the state received via JSON-RPC notifications. ## High-Level Architecture @@ -20,8 +20,8 @@ graph TD AppShell --> Features Features --> Shared Shared --> Services - Services --> SignalR - SignalR --> Backend + Services --> RPC + RPC --> Backend ``` ### 1. Application Layer (`src/app`) @@ -41,26 +41,26 @@ Contains the UI logic for specific user workflows. Reusable components and utilities. - **`components`**: Generic UI elements (Buttons, Inputs, Modals). - **`contexts`**: Global state containers (`AppContext`, `JobContext`). -- **`services`**: API clients and SignalR integration. +- **`services`**: API clients and JSON-RPC integration. ## Communication Layer -### SignalR Client -Located in `src/shared/services/signalr/`. -- **Auto-reconnect:** Automatically handles connection drops. -- **Queueing:** Buffers requests if the connection is temporarily lost. -- **Typed Events:** Strongly typed listeners for `GroupProgress`, `JobStatus`, etc. +### RPC Client +Located in `src/shared/services/rpc/`. +- **IPC transport:** Uses Electron bridge + backend stdio JSON-RPC. +- **Typed calls:** Strongly typed wrappers for backend methods. +- **Notifications:** Subscribes to `jobs.updated` events. ### API Facade Located in `src/shared/services/backend/`. - Provides a clean, Promise-based API for interacting with the backend. -- Wraps SignalR calls to abstract the underlying transport. +- Wraps JSON-RPC calls to abstract the underlying transport. ## Data Flow 1. **User Action:** User clicks "Start Job" in the `create-task` feature. 2. **Service Call:** Component calls `BackendService.createJob()`. -3. **Transmission:** Request is sent via SignalR WebSocket. +3. **Transmission:** Request is sent through JSON-RPC over Electron IPC. 4. **Backend Processing:** Backend creates the job and returns an ID. 5. **Notification:** Backend pushes a `JobStatus` event (Pending). 6. **Update:** `JobContext` receives the event and updates the global state. diff --git a/frontend/docs/vi/development.md b/frontend/docs/vi/development.md index 7db66d52..5361ceac 100644 --- a/frontend/docs/vi/development.md +++ b/frontend/docs/vi/development.md @@ -43,7 +43,7 @@ src/ │ ├── components/ # UI components nguyên tử (Buttons, Inputs) │ ├── contexts/ # React Contexts (JobContext, AppContext) │ ├── hooks/ # Custom React Hooks -│ ├── services/ # API & SignalR clients +│ ├── services/ # API & RPC clients │ └── styles/ # Global SCSS & Variables └── assets/ # Tài nguyên tĩnh (Images, Fonts) ``` diff --git a/frontend/docs/vi/overview.md b/frontend/docs/vi/overview.md index f83d0a53..0e5145cc 100644 --- a/frontend/docs/vi/overview.md +++ b/frontend/docs/vi/overview.md @@ -9,7 +9,7 @@ Frontend của SlideGenerator là một ứng dụng desktop chuyên biệt đư 2. Cung cấp khả năng giám sát thời gian thực các tiến trình nền. 3. Quản lý các cài đặt ứng dụng cục bộ và giao diện (theme). -**Nguyên lý cốt lõi:** Frontend là dạng "Thin Client". Nó chứa rất ít logic nghiệp vụ. Backend mới là nơi chứa sự thật (source of truth) cho mọi trạng thái job. Giao diện chỉ phản ánh trạng thái nhận được qua SignalR. +**Nguyên lý cốt lõi:** Frontend là dạng "Thin Client". Nó chứa rất ít logic nghiệp vụ. Backend mới là nơi chứa sự thật (source of truth) cho mọi trạng thái job. Giao diện chỉ phản ánh trạng thái nhận được qua thông báo JSON-RPC. ## Kiến trúc Mức cao @@ -20,8 +20,8 @@ graph TD AppShell --> Features Features --> Shared Shared --> Services - Services --> SignalR - SignalR --> Backend + Services --> RPC + RPC --> Backend ``` ### 1. Tầng Ứng dụng (`src/app`) @@ -41,26 +41,26 @@ Chứa logic UI cho các luồng công việc cụ thể của người dùng. Các component và tiện ích tái sử dụng. - **`components`**: Các phần tử UI chung (Buttons, Inputs, Modals). - **`contexts`**: Các container trạng thái toàn cục (`AppContext`, `JobContext`). -- **`services`**: Tích hợp API client và SignalR. +- **`services`**: Tích hợp API client và JSON-RPC. ## Tầng Giao tiếp -### SignalR Client -Nằm tại `src/shared/services/signalr/`. -- **Tự động kết nối lại:** Tự động xử lý khi mất kết nối. -- **Hàng đợi (Queueing):** Đệm các request nếu kết nối bị mất tạm thời. -- **Typed Events:** Các listener định kiểu mạnh cho `GroupProgress`, `JobStatus`, v.v. +### RPC Client +Nằm tại `src/shared/services/rpc/`. +- **IPC transport:** Dùng cầu nối Electron + backend stdio JSON-RPC. +- **Typed calls:** Wrapper định kiểu mạnh cho các method backend. +- **Notifications:** Lắng nghe sự kiện `jobs.updated`. ### API Facade Nằm tại `src/shared/services/backend/`. - Cung cấp API sạch, dựa trên Promise để tương tác với backend. -- Bọc các gọi SignalR để trừu tượng hóa lớp vận chuyển bên dưới. +- Bọc các gọi JSON-RPC để trừu tượng hóa lớp vận chuyển bên dưới. ## Luồng Dữ liệu 1. **Hành động người dùng:** Người dùng nhấn "Start Job" trong tính năng `create-task`. 2. **Gọi Service:** Component gọi `BackendService.createJob()`. -3. **Truyền tải:** Yêu cầu được gửi qua SignalR WebSocket. +3. **Truyền tải:** Yêu cầu được gửi qua JSON-RPC trên Electron IPC. 4. **Xử lý Backend:** Backend tạo job và trả về một ID. 5. **Thông báo:** Backend đẩy sự kiện `JobStatus` (Pending). 6. **Cập nhật:** `JobContext` nhận sự kiện và cập nhật state toàn cục. diff --git a/frontend/electron/main.ts b/frontend/electron/main.ts index eb29340c..428bb404 100644 --- a/frontend/electron/main.ts +++ b/frontend/electron/main.ts @@ -33,6 +33,12 @@ app.commandLine.appendSwitch('remote-debugging-port', '9222'); app.whenReady().then(() => { backendController.startBackend(); + backendController.onNotification((method, params) => { + BrowserWindow.getAllWindows().forEach((window) => { + window.webContents.send('backend:notification', { method, params }); + }); + }); + createMainWindow({ preloadPath, getAssetPath, @@ -75,3 +81,7 @@ app.on('before-quit', async (event) => { ipcMain.handle('backend:restart', async () => { return backendController.restartBackend(); }); + +ipcMain.handle('backend:request', async (_event, method: string, params?: unknown) => { + return backendController.request(method, params); +}); diff --git a/frontend/electron/main/backend.ts b/frontend/electron/main/backend.ts index 6bd8a1b4..8f219320 100644 --- a/frontend/electron/main/backend.ts +++ b/frontend/electron/main/backend.ts @@ -3,6 +3,12 @@ import { spawn, ChildProcess } from 'child_process'; import path from 'path'; import fsSync from 'fs'; import log from 'electron-log'; +import { + createMessageConnection, + StreamMessageReader, + StreamMessageWriter, + type MessageConnection, +} from 'vscode-jsonrpc/node'; interface BackendLaunch { command: string; @@ -38,13 +44,13 @@ const resolveBackendCommand = (): BackendLaunch | null => { if (app.isPackaged) { const backendRoot = path.join(process.resourcesPath, 'backend'); - const exePath = path.join(backendRoot, 'SlideGenerator.Presentation.exe'); + const exePath = path.join(backendRoot, 'SlideGenerator.Ipc.exe'); if (fsSync.existsSync(exePath)) { return { command: exePath, args: [], cwd: backendRoot }; } - const dllPath = path.join(backendRoot, 'SlideGenerator.Presentation.dll'); + const dllPath = path.join(backendRoot, 'SlideGenerator.Ipc.dll'); if (fsSync.existsSync(dllPath)) { return { command: 'dotnet', args: [dllPath], cwd: backendRoot }; } @@ -55,6 +61,8 @@ const resolveBackendCommand = (): BackendLaunch | null => { export const createBackendController = (backendLogPath: string) => { let backendProcess: ChildProcess | null = null; + let connection: MessageConnection | null = null; + const notificationHandlers = new Set<(method: string, params: unknown) => void>(); const startBackend = () => { if (!shouldStartBackend() || backendProcess) return; @@ -64,7 +72,7 @@ export const createBackendController = (backendLogPath: string) => { backendProcess = spawn(launch.command, launch.args, { cwd: launch.cwd, windowsHide: true, - stdio: 'ignore', + stdio: ['pipe', 'pipe', 'pipe'], detached: false, env: { ...process.env, @@ -72,8 +80,44 @@ export const createBackendController = (backendLogPath: string) => { }, }); + if (!backendProcess.stdin || !backendProcess.stdout) { + log.error('Backend process missing stdin/stdout for JSON-RPC communication.'); + backendProcess.kill(); + backendProcess = null; + return; + } + + if (backendProcess.stderr) { + backendProcess.stderr.on('data', (chunk: Buffer) => { + log.warn(`[backend] ${chunk.toString().trimEnd()}`); + }); + } + + connection = createMessageConnection( + new StreamMessageReader(backendProcess.stdout), + new StreamMessageWriter(backendProcess.stdin), + ); + + connection.onNotification((method: string, params: unknown) => { + notificationHandlers.forEach((handler) => { + try { + handler(method, params); + } catch (error) { + log.error('Backend notification handler error:', error); + } + }); + }); + + connection.listen(); + backendProcess.on('exit', (code) => { log.info(`Backend process exited with code ${code}`); + try { + connection?.dispose(); + } catch { + // no-op + } + connection = null; backendProcess = null; }); }; @@ -82,6 +126,12 @@ export const createBackendController = (backendLogPath: string) => { if (!backendProcess) return; const proc = backendProcess; backendProcess = null; + try { + connection?.dispose(); + } catch { + // no-op + } + connection = null; return new Promise((resolve) => { const timeout = setTimeout(() => { @@ -116,9 +166,30 @@ export const createBackendController = (backendLogPath: string) => { return Boolean(backendProcess); }; + const request = async (method: string, params?: unknown): Promise => { + if (!backendProcess || !connection) { + startBackend(); + } + + if (!connection) { + throw new Error('Backend JSON-RPC connection is not available.'); + } + + return connection.sendRequest(method, params) as Promise; + }; + + const onNotification = (handler: (method: string, params: unknown) => void) => { + notificationHandlers.add(handler); + return () => { + notificationHandlers.delete(handler); + }; + }; + return { startBackend, stopBackend, restartBackend, + request, + onNotification, }; }; diff --git a/frontend/electron/preload/api.ts b/frontend/electron/preload/api.ts index 82131197..94970c18 100644 --- a/frontend/electron/preload/api.ts +++ b/frontend/electron/preload/api.ts @@ -17,6 +17,8 @@ export interface UpdateState { } export interface ElectronAPI { + backendRequest: (method: string, params?: unknown) => Promise; + onBackendNotification: (handler: (payload: { method: string; params: unknown }) => void) => () => void; openFile: (filters?: { name: string; extensions: string[] }[]) => Promise; openMultipleFiles: ( filters?: { name: string; extensions: string[] }[], @@ -50,6 +52,14 @@ export interface ElectronAPI { export const createElectronAPI = (ipcRenderer: IpcRenderer): ElectronAPI => { return { + backendRequest: (method, params) => ipcRenderer.invoke('backend:request', method, params), + onBackendNotification: (handler) => { + const listener = (_event: IpcRendererEvent, payload: unknown) => { + handler(payload as { method: string; params: unknown }); + }; + ipcRenderer.on('backend:notification', listener); + return () => ipcRenderer.removeListener('backend:notification', listener); + }, openFile: (filters) => ipcRenderer.invoke('dialog:openFile', filters), openMultipleFiles: (filters) => ipcRenderer.invoke('dialog:openMultipleFiles', filters), openFolder: () => ipcRenderer.invoke('dialog:openFolder'), diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e2f195a8..74bdd791 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,37 +9,37 @@ "version": "1.1.0", "license": "GPL-3.0-only", "dependencies": { - "@microsoft/signalr": "^10.0.0", "electron-log": "^5.4.3", - "electron-updater": "^6.7.3", - "react": "^19.2.3", - "react-dom": "^19.2.3" + "electron-updater": "^6.8.3", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "vscode-jsonrpc": "^8.2.1" }, "devDependencies": { "@testing-library/jest-dom": "^6.9.1", - "@testing-library/react": "^16.3.1", + "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", - "@types/react": "^19.2.8", + "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^5.1.2", + "@vitejs/plugin-react": "^5.1.4", "concurrently": "^9.2.1", - "electron": "^40.0.0", - "electron-builder": "^26.4.0", - "jsdom": "^27.4.0", - "msw": "^2.12.7", - "prettier": "^3.8.0", + "electron": "^40.6.0", + "electron-builder": "^26.8.1", + "jsdom": "^28.1.0", + "msw": "^2.12.10", + "prettier": "^3.8.1", "typescript": "^5.9.3", "vite": "^7.3.1", "vite-plugin-electron": "^0.29.0", "vite-plugin-electron-renderer": "^0.14.6", - "vitest": "^4.0.17", - "wait-on": "^9.0.3" + "vitest": "^4.0.18", + "wait-on": "^9.0.4" } }, "node_modules/@acemir/cssom": { - "version": "0.9.30", - "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.30.tgz", - "integrity": "sha512-9CnlMCI0LmCIq0olalQqdWrJHPzm0/tw3gzOA9zJSgvFX7Xau3D24mAGa4BtwxwY69nsuJW6kQqqCzf/mEcQgg==", + "version": "0.9.31", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", "dev": true, "license": "MIT" }, @@ -51,23 +51,23 @@ "license": "MIT" }, "node_modules/@asamuzakjp/css-color": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.1.tgz", - "integrity": "sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz", + "integrity": "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==", "dev": true, "license": "MIT", "dependencies": { - "@csstools/css-calc": "^2.1.4", - "@csstools/css-color-parser": "^3.1.0", - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4", - "lru-cache": "^11.2.4" + "@csstools/css-calc": "^3.0.0", + "@csstools/css-color-parser": "^4.0.1", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.5" } }, "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { - "version": "11.2.4", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", - "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -75,9 +75,9 @@ } }, "node_modules/@asamuzakjp/dom-selector": { - "version": "6.7.6", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.6.tgz", - "integrity": "sha512-hBaJER6A9MpdG3WgdlOolHmbOYvSk46y7IQN/1+iqiCuUu6iWdQrs9DGKF8ocqsEqWujWf/V7b7vaDgiUmIvUg==", + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", + "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", "dev": true, "license": "MIT", "dependencies": { @@ -85,13 +85,13 @@ "bidi-js": "^1.0.3", "css-tree": "^3.1.0", "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.2.4" + "lru-cache": "^11.2.6" } }, "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { - "version": "11.2.4", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", - "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -106,13 +106,13 @@ "license": "MIT" }, "node_modules/@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -121,9 +121,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", - "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", "dev": true, "license": "MIT", "engines": { @@ -131,21 +131,21 @@ } }, "node_modules/@babel/core": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", - "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-module-transforms": "^7.28.3", - "@babel/helpers": "^7.28.4", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/traverse": "^7.28.5", - "@babel/types": "^7.28.5", + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -162,14 +162,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", - "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.5", - "@babel/types": "^7.28.5", + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -179,13 +179,13 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", - "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.27.2", + "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", @@ -206,29 +206,29 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", - "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", - "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.28.3" + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -278,27 +278,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", - "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.4" + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", - "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.28.5" + "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -350,33 +350,33 @@ } }, "node_modules/@babel/template": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", - "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/parser": "^7.27.2", - "@babel/types": "^7.27.1" + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", - "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.5", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", "debug": "^4.3.1" }, "engines": { @@ -384,9 +384,9 @@ } }, "node_modules/@babel/types": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", - "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", "dev": true, "license": "MIT", "dependencies": { @@ -397,10 +397,23 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, "node_modules/@csstools/color-helpers": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", - "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", "dev": true, "funding": [ { @@ -414,13 +427,13 @@ ], "license": "MIT-0", "engines": { - "node": ">=18" + "node": ">=20.19.0" } }, "node_modules/@csstools/css-calc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", - "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", + "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", "dev": true, "funding": [ { @@ -434,17 +447,17 @@ ], "license": "MIT", "engines": { - "node": ">=18" + "node": ">=20.19.0" }, "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" } }, "node_modules/@csstools/css-color-parser": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", - "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", + "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", "dev": true, "funding": [ { @@ -458,21 +471,21 @@ ], "license": "MIT", "dependencies": { - "@csstools/color-helpers": "^5.1.0", - "@csstools/css-calc": "^2.1.4" + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.1.1" }, "engines": { - "node": ">=18" + "node": ">=20.19.0" }, "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" } }, "node_modules/@csstools/css-parser-algorithms": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", - "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", "dev": true, "funding": [ { @@ -486,16 +499,16 @@ ], "license": "MIT", "engines": { - "node": ">=18" + "node": ">=20.19.0" }, "peerDependencies": { - "@csstools/css-tokenizer": "^3.0.4" + "@csstools/css-tokenizer": "^4.0.0" } }, "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.0.25", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.25.tgz", - "integrity": "sha512-g0Kw9W3vjx5BEBAF8c5Fm2NcB/Fs8jJXh85aXqwEXiL+tqtOut07TWgyaGzAAfTM+gKckrrncyeGEZPcaRgm2Q==", + "version": "1.0.28", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.28.tgz", + "integrity": "sha512-1NRf1CUBjnr3K7hu8BLxjQrKCxEe8FP/xmPTenAxCRZWVLbmGotkFvG9mfNpjA6k7Bw1bw4BilZq9cu19RA5pg==", "dev": true, "funding": [ { @@ -507,15 +520,12 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT-0", - "engines": { - "node": ">=18" - } + "license": "MIT-0" }, "node_modules/@csstools/css-tokenizer": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", - "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", "dev": true, "funding": [ { @@ -529,7 +539,7 @@ ], "license": "MIT", "engines": { - "node": ">=18" + "node": ">=20.19.0" } }, "node_modules/@develar/schema-utils": { @@ -568,6 +578,24 @@ "node": ">=10.12.0" } }, + "node_modules/@electron/asar/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@electron/asar/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/@electron/asar/node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -762,14 +790,13 @@ } }, "node_modules/@electron/rebuild": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-4.0.1.tgz", - "integrity": "sha512-iMGXb6Ib7H/Q3v+BKZJoETgF9g6KMNZVbsO4b7Dmpgb5qTFqyFTzqW9F3TOSHdybv2vKYKzSS9OiZL+dcJb+1Q==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-4.0.3.tgz", + "integrity": "sha512-u9vpTHRMkOYCs/1FLiSVAFZ7FbjsXK+bQuzviJZa+lG7BHZl1nz52/IcGvwa3sk80/fc3llutBkbCq10Vh8WQA==", "dev": true, "license": "MIT", "dependencies": { "@malept/cross-spawn-promise": "^2.0.0", - "chalk": "^4.0.0", "debug": "^4.1.1", "detect-libc": "^2.0.1", "got": "^11.7.0", @@ -780,7 +807,7 @@ "ora": "^5.1.0", "read-binary-file-arch": "^1.0.6", "semver": "^7.3.5", - "tar": "^6.0.5", + "tar": "^7.5.6", "yargs": "^17.0.1" }, "bin": { @@ -791,9 +818,9 @@ } }, "node_modules/@electron/rebuild/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "dev": true, "license": "ISC", "bin": { @@ -822,6 +849,13 @@ "node": ">=16.4" } }, + "node_modules/@electron/universal/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/@electron/universal/node_modules/brace-expansion": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", @@ -1395,19 +1429,19 @@ } }, "node_modules/@exodus/bytes": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.8.0.tgz", - "integrity": "sha512-8JPn18Bcp8Uo1T82gR8lh2guEOa5KKU/IEKvvdp0sgmi7coPBWf1Doi1EXsGZb2ehc8ym/StJCjffYV+ne7sXQ==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.14.1.tgz", + "integrity": "sha512-OhkBFWI6GcRMUroChZiopRiSp2iAMvEBK47NhJooDqz1RERO4QuZIZnjP63TXX8GAiLABkYmX+fuQsdJ1dd2QQ==", "dev": true, "license": "MIT", "engines": { "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@exodus/crypto": "^1.0.0-rc.4" + "@noble/hashes": "^1.8.0 || ^2.0.0" }, "peerDependenciesMeta": { - "@exodus/crypto": { + "@noble/hashes": { "optional": true } } @@ -1582,29 +1616,6 @@ } } }, - "node_modules/@isaacs/balanced-match": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", - "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@isaacs/brace-expansion": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", - "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@isaacs/balanced-match": "^4.0.1" - }, - "engines": { - "node": "20 || >=22" - } - }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -1849,23 +1860,10 @@ "node": ">= 10.0.0" } }, - "node_modules/@microsoft/signalr": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-10.0.0.tgz", - "integrity": "sha512-0BRqz/uCx3JdrOqiqgFhih/+hfTERaUfCZXFB52uMaZJrKaPRzHzMuqVsJC/V3pt7NozcNXGspjKiQEK+X7P2w==", - "license": "MIT", - "dependencies": { - "abort-controller": "^3.0.0", - "eventsource": "^2.0.2", - "fetch-cookie": "^2.0.3", - "node-fetch": "^2.6.7", - "ws": "^7.5.10" - } - }, "node_modules/@mswjs/interceptors": { - "version": "0.40.0", - "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.40.0.tgz", - "integrity": "sha512-EFd6cVbHsgLa6wa4RljGj6Wk75qoHxUSyc5asLyyPSyuhIcdS2Q3Phw6ImS1q+CkALthJRShiYfKANcQMuMqsQ==", + "version": "0.41.3", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.3.tgz", + "integrity": "sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA==", "dev": true, "license": "MIT", "dependencies": { @@ -1918,9 +1916,9 @@ } }, "node_modules/@npmcli/fs/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "dev": true, "license": "ISC", "bin": { @@ -1967,9 +1965,9 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.53", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz", - "integrity": "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==", + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", "dev": true, "license": "MIT" }, @@ -2363,9 +2361,9 @@ "license": "MIT" }, "node_modules/@testing-library/react": { - "version": "16.3.1", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.1.tgz", - "integrity": "sha512-gr4KtAWqIOQoucWYD/f6ki+j5chXfcPc74Col/6poTyqTmn7zRmodWahWRCp8tYd+GMqBonw6hstNzqjbs6gjw==", + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", "dev": true, "license": "MIT", "dependencies": { @@ -2562,9 +2560,9 @@ } }, "node_modules/@types/react": { - "version": "19.2.8", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.8.tgz", - "integrity": "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==", + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "dev": true, "license": "MIT", "dependencies": { @@ -2618,16 +2616,16 @@ } }, "node_modules/@vitejs/plugin-react": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.2.tgz", - "integrity": "sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ==", + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.4.tgz", + "integrity": "sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/core": "^7.28.5", + "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-beta.53", + "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, @@ -2639,16 +2637,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.17.tgz", - "integrity": "sha512-mEoqP3RqhKlbmUmntNDDCJeTDavDR+fVYkSOw8qRwJFaW/0/5zA9zFeTrHqNtcmwh6j26yMmwx2PqUDPzt5ZAQ==", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", + "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.0.17", - "@vitest/utils": "4.0.17", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" }, @@ -2657,13 +2655,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.17.tgz", - "integrity": "sha512-+ZtQhLA3lDh1tI2wxe3yMsGzbp7uuJSWBM1iTIKCbppWTSBN09PUC+L+fyNlQApQoR+Ps8twt2pbSSXg2fQVEQ==", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", + "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.0.17", + "@vitest/spy": "4.0.18", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -2684,9 +2682,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.17.tgz", - "integrity": "sha512-Ah3VAYmjcEdHg6+MwFE17qyLqBHZ+ni2ScKCiW2XrlSBV4H3Z7vYfPfz7CWQ33gyu76oc0Ai36+kgLU3rfF4nw==", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", + "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", "dev": true, "license": "MIT", "dependencies": { @@ -2697,13 +2695,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.17.tgz", - "integrity": "sha512-JmuQyf8aMWoo/LmNFppdpkfRVHJcsgzkbCA+/Bk7VfNH7RE6Ut2qxegeyx2j3ojtJtKIbIGy3h+KxGfYfk28YQ==", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", + "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.0.17", + "@vitest/utils": "4.0.18", "pathe": "^2.0.3" }, "funding": { @@ -2711,13 +2709,13 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.17.tgz", - "integrity": "sha512-npPelD7oyL+YQM2gbIYvlavlMVWUfNNGZPcu0aEUQXt7FXTuqhmgiYupPnAanhKvyP6Srs2pIbWo30K0RbDtRQ==", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", + "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.17", + "@vitest/pretty-format": "4.0.18", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -2726,9 +2724,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.17.tgz", - "integrity": "sha512-I1bQo8QaP6tZlTomQNWKJE6ym4SHf3oLS7ceNjozxxgzavRAgZDc06T7kD8gb9bXKEgcLNt00Z+kZO6KaJ62Ew==", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", + "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", "dev": true, "license": "MIT", "funding": { @@ -2736,13 +2734,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.17.tgz", - "integrity": "sha512-RG6iy+IzQpa9SB8HAFHJ9Y+pTzI+h8553MrciN9eC6TFBErqrQaTas4vG+MVj8S4uKk8uTT2p0vgZPnTdxd96w==", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", + "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.17", + "@vitest/pretty-format": "4.0.18", "tinyrainbow": "^3.0.3" }, "funding": { @@ -2776,18 +2774,6 @@ "node": "^18.17.0 || >=20.5.0" } }, - "node_modules/abort-controller": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", - "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", - "license": "MIT", - "dependencies": { - "event-target-shim": "^5.0.0" - }, - "engines": { - "node": ">=6.5" - } - }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -2799,9 +2785,9 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", "dependencies": { @@ -2859,23 +2845,24 @@ "license": "MIT" }, "node_modules/app-builder-lib": { - "version": "26.4.0", - "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-26.4.0.tgz", - "integrity": "sha512-Uas6hNe99KzP3xPWxh5LGlH8kWIVjZixzmMJHNB9+6hPyDpjc7NQMkVgi16rQDdpCFy22ZU5sp8ow7tvjeMgYQ==", + "version": "26.8.1", + "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-26.8.1.tgz", + "integrity": "sha512-p0Im/Dx5C4tmz8QEE1Yn4MkuPC8PrnlRneMhWJj7BBXQfNTJUshM/bp3lusdEsDbvvfJZpXWnYesgSLvwtM2Zw==", "dev": true, "license": "MIT", "dependencies": { "@develar/schema-utils": "~2.6.5", "@electron/asar": "3.4.1", "@electron/fuses": "^1.8.0", + "@electron/get": "^3.0.0", "@electron/notarize": "2.5.0", "@electron/osx-sign": "1.3.3", - "@electron/rebuild": "4.0.1", + "@electron/rebuild": "^4.0.3", "@electron/universal": "2.0.3", "@malept/flatpak-bundler": "^0.4.0", "@types/fs-extra": "9.0.13", "async-exit-hook": "^2.0.1", - "builder-util": "26.3.4", + "builder-util": "26.8.1", "builder-util-runtime": "9.5.1", "chromium-pickle-js": "^0.2.0", "ci-info": "4.3.1", @@ -2883,7 +2870,7 @@ "dotenv": "^16.4.5", "dotenv-expand": "^11.0.6", "ejs": "^3.1.8", - "electron-publish": "26.3.4", + "electron-publish": "26.8.1", "fs-extra": "^10.1.0", "hosted-git-info": "^4.1.0", "isbinaryfile": "^5.0.0", @@ -2893,9 +2880,10 @@ "lazy-val": "^1.0.5", "minimatch": "^10.0.3", "plist": "3.1.0", + "proper-lockfile": "^4.1.2", "resedit": "^1.7.0", "semver": "~7.7.3", - "tar": "^6.1.12", + "tar": "^7.5.7", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0", "which": "^5.0.0" @@ -2904,14 +2892,77 @@ "node": ">=14.0.0" }, "peerDependencies": { - "dmg-builder": "26.4.0", - "electron-builder-squirrel-windows": "26.4.0" + "dmg-builder": "26.8.1", + "electron-builder-squirrel-windows": "26.8.1" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-3.1.0.tgz", + "integrity": "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^2.2.0", + "fs-extra": "^8.1.0", + "got": "^11.8.5", + "progress": "^2.0.3", + "semver": "^6.2.0", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=14" + }, + "optionalDependencies": { + "global-agent": "^3.0.0" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/app-builder-lib/node_modules/ci-info": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", + "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" } }, "node_modules/app-builder-lib/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "dev": true, "license": "ISC", "bin": { @@ -3004,23 +3055,26 @@ } }, "node_modules/axios": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", - "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", + "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", "dev": true, "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.3.tgz", + "integrity": "sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": "20 || >=22" + } }, "node_modules/base64-js": { "version": "1.5.1", @@ -3044,13 +3098,16 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.8.30", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.30.tgz", - "integrity": "sha512-aTUKW4ptQhS64+v2d6IkPzymEzzhw+G0bA1g3uBRV3+ntkH+svttKseW5IOR4Ed6NUVKqnY7qT3dKvzQ7io4AA==", + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", "dev": true, "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.js" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/bidi-js": { @@ -3085,20 +3142,22 @@ "optional": true }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz", + "integrity": "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" } }, "node_modules/browserslist": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.0.tgz", - "integrity": "sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ==", + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", "dev": true, "funding": [ { @@ -3116,11 +3175,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.8.25", - "caniuse-lite": "^1.0.30001754", - "electron-to-chromium": "^1.5.249", + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", - "update-browserslist-db": "^1.1.4" + "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" @@ -3172,9 +3231,9 @@ "license": "MIT" }, "node_modules/builder-util": { - "version": "26.3.4", - "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-26.3.4.tgz", - "integrity": "sha512-aRn88mYMktHxzdqDMF6Ayj0rKoX+ZogJ75Ck7RrIqbY/ad0HBvnS2xA4uHfzrGr5D2aLL3vU6OBEH4p0KMV2XQ==", + "version": "26.8.1", + "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-26.8.1.tgz", + "integrity": "sha512-pm1lTYbGyc90DHgCDO7eo8Rl4EqKLciayNbZqGziqnH9jrlKe8ZANGdityLZU+pJh16dfzjAx2xQq9McuIPEtw==", "dev": true, "license": "MIT", "dependencies": { @@ -3233,6 +3292,13 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/cacache/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/cacache/node_modules/brace-expansion": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", @@ -3243,20 +3309,11 @@ "balanced-match": "^1.0.0" } }, - "node_modules/cacache/node_modules/chownr": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", - "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, "node_modules/cacache/node_modules/glob": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, "license": "ISC", "dependencies": { @@ -3297,33 +3354,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/cacache/node_modules/tar": { - "version": "7.5.2", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.2.tgz", - "integrity": "sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/fs-minipass": "^4.0.0", - "chownr": "^3.0.0", - "minipass": "^7.1.2", - "minizlib": "^3.1.0", - "yallist": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/cacache/node_modules/yallist": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", - "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, "node_modules/cacheable-lookup": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", @@ -3368,9 +3398,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001756", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001756.tgz", - "integrity": "sha512-4HnCNKbMLkLdhJz3TToeVWHSnfJvPaq6vu/eRP0Ahub/07n484XHhBF5AJoSGHdVrS8tKFauUQz8Bp9P7LVx7A==", + "version": "1.0.30001770", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001770.tgz", + "integrity": "sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw==", "dev": true, "funding": [ { @@ -3429,13 +3459,13 @@ } }, "node_modules/chownr": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", - "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "engines": { - "node": ">=10" + "node": ">=18" } }, "node_modules/chromium-pickle-js": { @@ -3446,9 +3476,9 @@ "license": "MIT" }, "node_modules/ci-info": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", - "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", "dev": true, "funding": [ { @@ -3747,25 +3777,25 @@ "license": "MIT" }, "node_modules/cssstyle": { - "version": "5.3.7", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.7.tgz", - "integrity": "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.0.1.tgz", + "integrity": "sha512-IoJs7La+oFp/AB033wBStxNOJt4+9hHMxsXUPANcoXL2b3W4DZKghlJ2cI/eyeRZIQ9ysvYEorVhjrcYctWbog==", "dev": true, "license": "MIT", "dependencies": { - "@asamuzakjp/css-color": "^4.1.1", - "@csstools/css-syntax-patches-for-csstree": "^1.0.21", + "@asamuzakjp/css-color": "^4.1.2", + "@csstools/css-syntax-patches-for-csstree": "^1.0.26", "css-tree": "^3.1.0", - "lru-cache": "^11.2.4" + "lru-cache": "^11.2.5" }, "engines": { "node": ">=20" } }, "node_modules/cssstyle/node_modules/lru-cache": { - "version": "11.2.4", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", - "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -3780,77 +3810,40 @@ "license": "MIT" }, "node_modules/data-urls": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.0.tgz", - "integrity": "sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", "dev": true, "license": "MIT", "dependencies": { - "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^15.0.0" + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" }, "engines": { - "node": ">=20" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, - "node_modules/data-urls/node_modules/tr46": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", - "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", - "dev": true, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { - "punycode": "^2.3.1" + "ms": "^2.1.3" }, "engines": { - "node": ">=20" + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, - "node_modules/data-urls/node_modules/webidl-conversions": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", - "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=20" - } - }, - "node_modules/data-urls/node_modules/whatwg-url": { - "version": "15.1.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", - "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "tr46": "^6.0.0", - "webidl-conversions": "^8.0.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/decimal.js": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", - "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", "dev": true, "license": "MIT" }, @@ -3993,6 +3986,24 @@ "p-limit": "^3.1.0 " } }, + "node_modules/dir-compare/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/dir-compare/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/dir-compare/node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -4007,14 +4018,14 @@ } }, "node_modules/dmg-builder": { - "version": "26.4.0", - "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-26.4.0.tgz", - "integrity": "sha512-ce4Ogns4VMeisIuCSK0C62umG0lFy012jd8LMZ6w/veHUeX4fqfDrGe+HTWALAEwK6JwKP+dhPvizhArSOsFbg==", + "version": "26.8.1", + "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-26.8.1.tgz", + "integrity": "sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg==", "dev": true, "license": "MIT", "dependencies": { - "app-builder-lib": "26.4.0", - "builder-util": "26.3.4", + "app-builder-lib": "26.8.1", + "builder-util": "26.8.1", "fs-extra": "^10.1.0", "iconv-lite": "^0.6.2", "js-yaml": "^4.1.0" @@ -4126,9 +4137,9 @@ } }, "node_modules/electron": { - "version": "40.0.0", - "resolved": "https://registry.npmjs.org/electron/-/electron-40.0.0.tgz", - "integrity": "sha512-UyBy5yJ0/wm4gNugCtNPjvddjAknMTuXR2aCHioXicH7aKRKGDBPp4xqTEi/doVcB3R+MN3wfU9o8d/9pwgK2A==", + "version": "40.6.0", + "resolved": "https://registry.npmjs.org/electron/-/electron-40.6.0.tgz", + "integrity": "sha512-ett8W+yOFGDuM0vhJMamYSkrbV3LoaffzJd9GfjI96zRAxyrNqUSKqBpf/WGbQCweDxX2pkUCUfrv4wwKpsFZA==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -4145,18 +4156,18 @@ } }, "node_modules/electron-builder": { - "version": "26.4.0", - "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-26.4.0.tgz", - "integrity": "sha512-FCUqvdq2AULL+Db2SUGgjOYTbrgkPxZtCjqIZGnjH9p29pTWyesQqBIfvQBKa6ewqde87aWl49n/WyI/NyUBog==", + "version": "26.8.1", + "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-26.8.1.tgz", + "integrity": "sha512-uWhx1r74NGpCagG0ULs/P9Nqv2nsoo+7eo4fLUOB8L8MdWltq9odW/uuLXMFCDGnPafknYLZgjNX0ZIFRzOQAw==", "dev": true, "license": "MIT", "dependencies": { - "app-builder-lib": "26.4.0", - "builder-util": "26.3.4", + "app-builder-lib": "26.8.1", + "builder-util": "26.8.1", "builder-util-runtime": "9.5.1", "chalk": "^4.1.2", "ci-info": "^4.2.0", - "dmg-builder": "26.4.0", + "dmg-builder": "26.8.1", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", @@ -4171,15 +4182,15 @@ } }, "node_modules/electron-builder-squirrel-windows": { - "version": "26.4.0", - "resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-26.4.0.tgz", - "integrity": "sha512-7dvalY38xBzWNaoOJ4sqy2aGIEpl2S1gLPkkB0MHu1Hu5xKQ82il1mKSFlXs6fLpXUso/NmyjdHGlSHDRoG8/w==", + "version": "26.8.1", + "resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-26.8.1.tgz", + "integrity": "sha512-o288fIdgPLHA76eDrFADHPoo7VyGkDCYbLV1GzndaMSAVBoZrGvM9m2IehdcVMzdAZJ2eV9bgyissQXHv5tGzA==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "app-builder-lib": "26.4.0", - "builder-util": "26.3.4", + "app-builder-lib": "26.8.1", + "builder-util": "26.8.1", "electron-winstaller": "5.4.0" } }, @@ -4193,33 +4204,33 @@ } }, "node_modules/electron-publish": { - "version": "26.3.4", - "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.3.4.tgz", - "integrity": "sha512-5/ouDPb73SkKuay2EXisPG60LTFTMNHWo2WLrK5GDphnWK9UC+yzYrzVeydj078Yk4WUXi0+TaaZsNd6Zt5k/A==", + "version": "26.8.1", + "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.8.1.tgz", + "integrity": "sha512-q+jrSTIh/Cv4eGZa7oVR+grEJo/FoLMYBAnSL5GCtqwUpr1T+VgKB/dn1pnzxIxqD8S/jP1yilT9VrwCqINR4w==", "dev": true, "license": "MIT", "dependencies": { "@types/fs-extra": "^9.0.11", - "builder-util": "26.3.4", + "builder-util": "26.8.1", "builder-util-runtime": "9.5.1", "chalk": "^4.1.2", - "form-data": "^4.0.0", + "form-data": "^4.0.5", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "mime": "^2.5.2" } }, "node_modules/electron-to-chromium": { - "version": "1.5.259", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.259.tgz", - "integrity": "sha512-I+oLXgpEJzD6Cwuwt1gYjxsDmu/S/Kd41mmLA3O+/uH2pFRO/DvOjUyGozL8j3KeLV6WyZ7ssPwELMsXCcsJAQ==", + "version": "1.5.302", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz", + "integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==", "dev": true, "license": "ISC" }, "node_modules/electron-updater": { - "version": "6.7.3", - "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.7.3.tgz", - "integrity": "sha512-EgkT8Z9noqXKbwc3u5FkJA+r48jwZ5DTUiOkJMOTEEH//n5Am6wfQGz7nvSFEA2oIAMv9jRzn5JKTyWeSKOPgg==", + "version": "6.8.3", + "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.8.3.tgz", + "integrity": "sha512-Z6sgw3jgbikWKXei1ENdqFOxBP0WlXg3TtKfz0rgw2vIZFJUyI4pD7ZN7jrkm7EoMK+tcm/qTnPUdqfZukBlBQ==", "license": "MIT", "dependencies": { "builder-util-runtime": "9.5.1", @@ -4293,6 +4304,7 @@ "version": "0.1.13", "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -4479,24 +4491,6 @@ "@types/estree": "^1.0.0" } }, - "node_modules/event-target-shim": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", - "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/eventsource": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", - "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==", - "license": "MIT", - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -4588,16 +4582,6 @@ } } }, - "node_modules/fetch-cookie": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-2.2.0.tgz", - "integrity": "sha512-h9AgfjURuCgA2+2ISl8GbavpUdR+WGAM2McW/ovn4tVccegp8ZqCKWSBR8uRdM8dDNlx5WdKRWxBYUwteLDCNQ==", - "license": "Unlicense", - "dependencies": { - "set-cookie-parser": "^2.4.8", - "tough-cookie": "^4.0.0" - } - }, "node_modules/filelist": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", @@ -4608,6 +4592,13 @@ "minimatch": "^5.0.1" } }, + "node_modules/filelist/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/filelist/node_modules/brace-expansion": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", @@ -4858,7 +4849,7 @@ "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, "license": "ISC", "dependencies": { @@ -4876,6 +4867,24 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/glob/node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -5185,7 +5194,7 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -5325,13 +5334,13 @@ } }, "node_modules/isexe": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", - "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", + "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "engines": { - "node": ">=16" + "node": ">=18" } }, "node_modules/jackspeak": { @@ -5417,17 +5426,18 @@ } }, "node_modules/jsdom": { - "version": "27.4.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.4.0.tgz", - "integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==", + "version": "28.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz", + "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==", "dev": true, "license": "MIT", "dependencies": { - "@acemir/cssom": "^0.9.28", - "@asamuzakjp/dom-selector": "^6.7.6", - "@exodus/bytes": "^1.6.0", - "cssstyle": "^5.3.4", - "data-urls": "^6.0.0", + "@acemir/cssom": "^0.9.31", + "@asamuzakjp/dom-selector": "^6.8.1", + "@bramus/specificity": "^2.4.2", + "@exodus/bytes": "^1.11.0", + "cssstyle": "^6.0.1", + "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "http-proxy-agent": "^7.0.2", @@ -5437,11 +5447,11 @@ "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.0", + "undici": "^7.21.0", "w3c-xmlserializer": "^5.0.0", - "webidl-conversions": "^8.0.0", - "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^15.1.0", - "ws": "^8.18.3", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0", "xml-name-validator": "^5.0.0" }, "engines": { @@ -5469,65 +5479,6 @@ "node": ">=16" } }, - "node_modules/jsdom/node_modules/tr46": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", - "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", - "dev": true, - "license": "MIT", - "dependencies": { - "punycode": "^2.3.1" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/jsdom/node_modules/webidl-conversions": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", - "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=20" - } - }, - "node_modules/jsdom/node_modules/whatwg-url": { - "version": "15.1.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", - "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "tr46": "^6.0.0", - "webidl-conversions": "^8.0.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/jsdom/node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -5603,9 +5554,9 @@ "license": "MIT" }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "dev": true, "license": "MIT" }, @@ -5801,16 +5752,16 @@ } }, "node_modules/minimatch": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", - "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz", + "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "@isaacs/brace-expansion": "^5.0.0" + "brace-expansion": "^5.0.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -5827,11 +5778,11 @@ } }, "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "engines": { "node": ">=16 || 14 >=14.17" } @@ -5980,16 +5931,17 @@ } }, "node_modules/mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dev": true, "license": "MIT", + "peer": true, + "dependencies": { + "minimist": "^1.2.6" + }, "bin": { "mkdirp": "bin/cmd.js" - }, - "engines": { - "node": ">=10" } }, "node_modules/ms": { @@ -5999,15 +5951,15 @@ "license": "MIT" }, "node_modules/msw": { - "version": "2.12.7", - "resolved": "https://registry.npmjs.org/msw/-/msw-2.12.7.tgz", - "integrity": "sha512-retd5i3xCZDVWMYjHEVuKTmhqY8lSsxujjVrZiGbbdoxxIBg5S7rCuYy/YQpfrTYIxpd/o0Kyb/3H+1udBMoYg==", + "version": "2.12.10", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.12.10.tgz", + "integrity": "sha512-G3VUymSE0/iegFnuipujpwyTM2GuZAKXNeerUSrG2+Eg391wW63xFs5ixWsK9MWzr1AGoSkYGmyAzNgbR3+urw==", "dev": true, "hasInstallScript": true, "license": "MIT", "dependencies": { "@inquirer/confirm": "^5.0.0", - "@mswjs/interceptors": "^0.40.0", + "@mswjs/interceptors": "^0.41.2", "@open-draft/deferred-promise": "^2.2.0", "@types/statuses": "^2.0.6", "cookie": "^1.0.2", @@ -6017,7 +5969,7 @@ "outvariant": "^1.4.3", "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", - "rettime": "^0.7.0", + "rettime": "^0.10.1", "statuses": "^2.0.2", "strict-event-emitter": "^0.5.1", "tough-cookie": "^6.0.0", @@ -6112,9 +6064,9 @@ } }, "node_modules/node-abi": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.24.0.tgz", - "integrity": "sha512-u2EC1CeNe25uVtX3EZbdQ275c74zdZmmpzrHEQh2aIYqoVjlglfUpOX9YY85x1nlBydEKDVaSmMNhR7N82Qj8A==", + "version": "4.26.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.26.0.tgz", + "integrity": "sha512-8QwIZqikRvDIkXS2S93LjzhsSPJuIbfaMETWH+Bx8oOT9Sa9UsUtBFQlc3gBNd1+QINjaTloitXr1W3dQLi9Iw==", "dev": true, "license": "MIT", "dependencies": { @@ -6125,9 +6077,9 @@ } }, "node_modules/node-abi/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "dev": true, "license": "ISC", "bin": { @@ -6156,9 +6108,9 @@ } }, "node_modules/node-api-version/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "dev": true, "license": "ISC", "bin": { @@ -6168,26 +6120,6 @@ "node": ">=10" } }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, "node_modules/node-gyp": { "version": "11.5.0", "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-11.5.0.tgz", @@ -6213,20 +6145,10 @@ "node": "^18.17.0 || >=20.5.0" } }, - "node_modules/node-gyp/node_modules/chownr": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", - "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, "node_modules/node-gyp/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "dev": true, "license": "ISC", "bin": { @@ -6236,33 +6158,6 @@ "node": ">=10" } }, - "node_modules/node-gyp/node_modules/tar": { - "version": "7.5.2", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.2.tgz", - "integrity": "sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/fs-minipass": "^4.0.0", - "chownr": "^3.0.0", - "minipass": "^7.1.2", - "minizlib": "^3.1.0", - "yallist": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/node-gyp/node_modules/yallist": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", - "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, "node_modules/node-releases": { "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", @@ -6612,9 +6507,9 @@ } }, "node_modules/prettier": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.0.tgz", - "integrity": "sha512-yEPsovQfpxYfgWNhCfECjG5AQaO+K3dp6XERmOepyPDVqcJm+bjyCVO3pmU+nAPe0N5dDvekfGezt/EIiRe1TA==", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", "bin": { @@ -6691,6 +6586,18 @@ "node": ">=10" } }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -6698,18 +6605,6 @@ "dev": true, "license": "MIT" }, - "node_modules/psl": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", - "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", - "license": "MIT", - "dependencies": { - "punycode": "^2.3.1" - }, - "funding": { - "url": "https://github.com/sponsors/lupomontero" - } - }, "node_modules/pump": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", @@ -6725,17 +6620,12 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" } }, - "node_modules/querystringify": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", - "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", - "license": "MIT" - }, "node_modules/quick-lru": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", @@ -6750,24 +6640,24 @@ } }, "node_modules/react": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", - "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.3" + "react": "^19.2.4" } }, "node_modules/react-is": { @@ -6850,12 +6740,6 @@ "node": ">=0.10.0" } }, - "node_modules/requires-port": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", - "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", - "license": "MIT" - }, "node_modules/resedit": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz", @@ -6919,9 +6803,9 @@ } }, "node_modules/rettime": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.7.0.tgz", - "integrity": "sha512-LPRKoHnLKd/r3dVxcwO7vhCW+orkOGj9ViueosEBK6ie89CijnfRlhaDhHq/3Hxu4CkWQtxwlBG0mzTQY6uQjw==", + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.10.1.tgz", + "integrity": "sha512-uyDrIlUEH37cinabq0AX4QbgV4HbFZ/gqoiunWQ1UqBtRvTTytwhNYjE++pO/MjPTZL5KQCf2bEoJ/BJNVQ5Kw==", "dev": true, "license": "MIT" }, @@ -7036,7 +6920,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/sanitize-filename": { @@ -7112,12 +6996,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/set-cookie-parser": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", - "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", - "license": "MIT" - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -7475,93 +7353,32 @@ } }, "node_modules/tar": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", - "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "version": "7.5.9", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.9.tgz", + "integrity": "sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg==", "dev": true, - "license": "ISC", - "dependencies": { - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "minipass": "^5.0.0", - "minizlib": "^2.1.1", - "mkdirp": "^1.0.3", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/tar/node_modules/fs-minipass": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", - "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/tar/node_modules/fs-minipass/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "yallist": "^4.0.0" + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" }, "engines": { - "node": ">=8" + "node": ">=18" } }, - "node_modules/tar/node_modules/minipass": { + "node_modules/tar/node_modules/yallist": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", - "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=8" - } - }, - "node_modules/tar/node_modules/minizlib": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", - "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", - "dev": true, - "license": "MIT", - "dependencies": { - "minipass": "^3.0.0", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/tar/node_modules/minizlib/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, + "license": "BlueOak-1.0.0", "engines": { - "node": ">=8" + "node": ">=18" } }, - "node_modules/tar/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "license": "ISC" - }, "node_modules/temp": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/temp/-/temp-0.9.4.tgz", @@ -7588,20 +7405,6 @@ "fs-extra": "^10.0.0" } }, - "node_modules/temp/node_modules/mkdirp": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", - "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "minimist": "^1.2.6" - }, - "bin": { - "mkdirp": "bin/cmd.js" - } - }, "node_modules/tiny-async-pool": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz", @@ -7712,36 +7515,19 @@ "tmp": "^0.2.0" } }, - "node_modules/tough-cookie": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", - "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", - "license": "BSD-3-Clause", + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", "dependencies": { - "psl": "^1.1.33", - "punycode": "^2.1.1", - "universalify": "^0.2.0", - "url-parse": "^1.5.3" + "punycode": "^2.3.1" }, "engines": { - "node": ">=6" - } - }, - "node_modules/tough-cookie/node_modules/universalify": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", - "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", - "license": "MIT", - "engines": { - "node": ">= 4.0.0" + "node": ">=20" } }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT" - }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -7797,6 +7583,16 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "7.22.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz", + "integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", @@ -7851,9 +7647,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", - "integrity": "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", "dev": true, "funding": [ { @@ -7891,16 +7687,6 @@ "punycode": "^2.1.0" } }, - "node_modules/url-parse": { - "version": "1.5.10", - "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", - "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", - "license": "MIT", - "dependencies": { - "querystringify": "^2.1.1", - "requires-port": "^1.0.0" - } - }, "node_modules/utf8-byte-length": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", @@ -8029,19 +7815,19 @@ "license": "MIT" }, "node_modules/vitest": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.17.tgz", - "integrity": "sha512-FQMeF0DJdWY0iOnbv466n/0BudNdKj1l5jYgl5JVTwjSsZSlqyXFt/9+1sEyhR6CLowbZpV7O1sCHrzBhucKKg==", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", + "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.0.17", - "@vitest/mocker": "4.0.17", - "@vitest/pretty-format": "4.0.17", - "@vitest/runner": "4.0.17", - "@vitest/snapshot": "4.0.17", - "@vitest/spy": "4.0.17", - "@vitest/utils": "4.0.17", + "@vitest/expect": "4.0.18", + "@vitest/mocker": "4.0.18", + "@vitest/pretty-format": "4.0.18", + "@vitest/runner": "4.0.18", + "@vitest/snapshot": "4.0.18", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", @@ -8069,10 +7855,10 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.0.17", - "@vitest/browser-preview": "4.0.17", - "@vitest/browser-webdriverio": "4.0.17", - "@vitest/ui": "4.0.17", + "@vitest/browser-playwright": "4.0.18", + "@vitest/browser-preview": "4.0.18", + "@vitest/browser-webdriverio": "4.0.18", + "@vitest/ui": "4.0.18", "happy-dom": "*", "jsdom": "*" }, @@ -8106,6 +7892,15 @@ } } }, + "node_modules/vscode-jsonrpc": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.1.tgz", + "integrity": "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", @@ -8120,15 +7915,15 @@ } }, "node_modules/wait-on": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-9.0.3.tgz", - "integrity": "sha512-13zBnyYvFDW1rBvWiJ6Av3ymAaq8EDQuvxZnPIw3g04UqGi4TyoIJABmfJ6zrvKo9yeFQExNkOk7idQbDJcuKA==", + "version": "9.0.4", + "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-9.0.4.tgz", + "integrity": "sha512-k8qrgfwrPVJXTeFY8tl6BxVHiclK11u72DVKhpybHfUL/K6KM4bdyK9EhIVYGytB5MJe/3lq4Tf0hrjM+pvJZQ==", "dev": true, "license": "MIT", "dependencies": { - "axios": "^1.13.2", - "joi": "^18.0.1", - "lodash": "^4.17.21", + "axios": "^1.13.5", + "joi": "^18.0.2", + "lodash": "^4.17.23", "minimist": "^1.2.8", "rxjs": "^7.8.2" }, @@ -8150,29 +7945,38 @@ } }, "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause" + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } }, "node_modules/whatwg-mimetype": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", - "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", "dev": true, "license": "MIT", "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, "license": "MIT", "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, "node_modules/which": { @@ -8252,27 +8056,6 @@ "dev": true, "license": "ISC" }, - "node_modules/ws": { - "version": "7.5.10", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", - "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", - "license": "MIT", - "engines": { - "node": ">=8.3.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 1ac6be70..f0b2d7ad 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,7 +16,7 @@ "build:frontend": "tsc && vite build", "build:electron": "electron-builder", "build": "npm run build:types && npm run build:frontend && npm run build:electron", - "build:backend": "dotnet publish ../backend/src/SlideGenerator.Presentation/SlideGenerator.Presentation.csproj -c Release -o backend", + "build:backend": "dotnet publish ../backend/src/SlideGenerator.Ipc/SlideGenerator.Ipc.csproj -c Release -o backend", "build:full": "npm run build:backend && npm run build", "preview": "vite preview", "test": "vitest run", @@ -32,31 +32,31 @@ ], "author": "thnhmai06", "dependencies": { - "@microsoft/signalr": "^10.0.0", "electron-log": "^5.4.3", - "electron-updater": "^6.7.3", - "react": "^19.2.3", - "react-dom": "^19.2.3" + "electron-updater": "^6.8.3", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "vscode-jsonrpc": "^8.2.1" }, "devDependencies": { "@testing-library/jest-dom": "^6.9.1", - "@testing-library/react": "^16.3.1", + "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", - "@types/react": "^19.2.8", + "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^5.1.2", + "@vitejs/plugin-react": "^5.1.4", "concurrently": "^9.2.1", - "electron": "^40.0.0", - "electron-builder": "^26.4.0", - "jsdom": "^27.4.0", - "msw": "^2.12.7", - "prettier": "^3.8.0", + "electron": "^40.6.0", + "electron-builder": "^26.8.1", + "jsdom": "^28.1.0", + "msw": "^2.12.10", + "prettier": "^3.8.1", "typescript": "^5.9.3", "vite": "^7.3.1", "vite-plugin-electron": "^0.29.0", "vite-plugin-electron-renderer": "^0.14.6", - "vitest": "^4.0.17", - "wait-on": "^9.0.3" + "vitest": "^4.0.18", + "wait-on": "^9.0.4" }, "build": { "appId": "com.thnhmai06.slide-generator", diff --git a/frontend/src/features/create-task/CreateTaskMenu.tsx b/frontend/src/features/create-task/CreateTaskMenu.tsx index 687129ce..ff3bb596 100644 --- a/frontend/src/features/create-task/CreateTaskMenu.tsx +++ b/frontend/src/features/create-task/CreateTaskMenu.tsx @@ -36,9 +36,9 @@ const CreateTaskMenu: React.FC = ({ onStart }) => { {/* File Inputs */} = ({ - pptxPath, + slidePath, onChangePath, onBrowse, isLoadingShapes, @@ -13,14 +13,14 @@ export const TemplateInputSection: React.FC = ({ t, }) => (
- +
onChangePath(e.target.value)} - placeholder={t('createTask.pptxPlaceholder')} + placeholder={t('createTask.slidePlaceholder')} />