diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9cc826da..ffa9ddc1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -31,18 +31,33 @@ jobs: with: dotnet-version: "10.0.x" + - name: Detect backend tests + id: detect-backend-tests + shell: pwsh + run: | + $hasTests = (Get-ChildItem -Path backend -Recurse -Include *.Tests.csproj,*.UnitTests.csproj,*.IntegrationTests.csproj -ErrorAction SilentlyContinue | Select-Object -First 1) -ne $null + if (-not $hasTests) { + $hasTests = (Get-ChildItem -Path backend -Recurse -Filter *Test*.cs -ErrorAction SilentlyContinue | Select-Object -First 1) -ne $null + } + "has_tests=$($hasTests.ToString().ToLower())" >> $env:GITHUB_OUTPUT + - name: Run Backend Tests + if: steps.detect-backend-tests.outputs.has_tests == 'true' run: task test:backend + - name: Skip backend tests (none found) + if: steps.detect-backend-tests.outputs.has_tests == 'false' + run: echo "No backend tests found. Skipping backend test steps." + - name: Upload backend test results - if: always() + if: always() && steps.detect-backend-tests.outputs.has_tests == 'true' uses: actions/upload-artifact@v4 with: name: backend-test-results path: backend/TestResults/*.trx - name: Publish backend test report - if: always() + if: always() && steps.detect-backend-tests.outputs.has_tests == 'true' uses: dorny/test-reporter@v1 with: name: Backend Tests @@ -71,18 +86,33 @@ jobs: cache: npm cache-dependency-path: frontend/package-lock.json + - name: Detect frontend tests + id: detect-frontend-tests + shell: bash + run: | + if find frontend -type f \( -name "*.test.*" -o -name "*.spec.*" \) | grep -q .; then + echo "has_tests=true" >> "$GITHUB_OUTPUT" + else + echo "has_tests=false" >> "$GITHUB_OUTPUT" + fi + - name: Run Frontend Tests + if: steps.detect-frontend-tests.outputs.has_tests == 'true' run: task test:frontend + - name: Skip frontend tests (none found) + if: steps.detect-frontend-tests.outputs.has_tests == 'false' + run: echo "No frontend tests found. Skipping frontend test steps." + - name: Upload frontend test results - if: always() + if: always() && steps.detect-frontend-tests.outputs.has_tests == 'true' uses: actions/upload-artifact@v4 with: name: frontend-test-results path: frontend/test-results/junit.xml - name: Publish frontend test report - if: always() + if: always() && steps.detect-frontend-tests.outputs.has_tests == 'true' uses: dorny/test-reporter@v1 with: name: Frontend Tests diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b3d6e5f2..dfc74390 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/SlideGenerator.Ipc/SlideGenerator.Ipc.csproj \ -c Release \ -r $RID \ --self-contained false \ diff --git a/.gitmodules b/.gitmodules index 838702fc..f740e2cb 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,3 @@ -[submodule "backend/src/SlideGenerator.Framework"] - path = backend/src/SlideGenerator.Framework +[submodule "backend/SlideGenerator.Framework"] + path = backend/SlideGenerator.Framework url = https://github.com/thnhmai06/SlideGenerator.Framework diff --git a/.vscode/launch.json b/.vscode/launch.json index 18746450..5dcf806a 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/SlideGenerator.Ipc/SlideGenerator.Ipc.csproj" ], "cwd": "${workspaceFolder}", "console": "integratedTerminal", diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e63bfcfd..ec176ea4 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 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..c4667139 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/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..f92ac545 --- /dev/null +++ b/backend/.github/copilot-instructions.md @@ -0,0 +1,61 @@ +# 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 (slide/sheet/image/cloud services), no app orchestration. Can be published as NuGet. + - `Features`: domain entities/models (Configs, Jobs orchestration with persistence/workflow, Slides/Sheets domain models). + - `Services`: application services (ScanService, GenerateService, DownloadService, FaceDetectorModelManager, ValidationService). + - `Ipc`: JSON-RPC transport adapter. +- Configuration access for non-`Features` projects currently uses `IConfigProvider` mapping from singleton `ConfigManager` (in `Features.Configs`). + - 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 `Services`). + - 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` (in `Services.Generating`, uses 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 `Services.Generating.Models` (for GenerateSlidesRequest) or `SlideGenerator.Ipc/Contracts/Requests`. + - Keep RPC handlers in partial `RpcEndpoint.*.cs` files and call `BackendService` (in `Features.Jobs`) for orchestration. +- Framework reuse: + - If logic already exists in `Framework` services, use it instead of duplicating logic in `Services` or `Features`. +- Data shape conventions: + - Use `Entities` for domain/runtime entities (in `Features`). + - Use `Models` for supporting option/value model types (in `Features` or `Services`). +- **Current architecture**: Solution organized into 4 projects: + - `Framework` (low-level reusable library) + - `Features` (domain entities, Jobs, Configs, domain models) + - `Services` (application services for scan/generate/download/validation) + - `Ipc` (JSON-RPC transport layer) \ No newline at end of file diff --git a/backend/.idea/.idea.SlideGenerator/.idea/.gitignore b/backend/.idea/.idea.SlideGenerator/.idea/.gitignore new file mode 100644 index 00000000..34ea65fb --- /dev/null +++ b/backend/.idea/.idea.SlideGenerator/.idea/.gitignore @@ -0,0 +1,15 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Rider ignored files +/modules.xml +/contentModel.xml +/projectSettingsUpdater.xml +/.idea.SlideGenerator.iml +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/backend/.idea/.idea.SlideGenerator/.idea/.name b/backend/.idea/.idea.SlideGenerator/.idea/.name new file mode 100644 index 00000000..7f05768c --- /dev/null +++ b/backend/.idea/.idea.SlideGenerator/.idea/.name @@ -0,0 +1 @@ +SlideGenerator \ No newline at end of file diff --git a/backend/.idea/.idea.SlideGenerator/.idea/betterCommentsSettings.xml b/backend/.idea/.idea.SlideGenerator/.idea/betterCommentsSettings.xml new file mode 100644 index 00000000..4f152ed1 --- /dev/null +++ b/backend/.idea/.idea.SlideGenerator/.idea/betterCommentsSettings.xml @@ -0,0 +1,31 @@ + + + + + + \ No newline at end of file diff --git a/backend/.idea/.idea.SlideGenerator/.idea/copilot.data.migration.agent.xml b/backend/.idea/.idea.SlideGenerator/.idea/copilot.data.migration.agent.xml new file mode 100644 index 00000000..4ea72a91 --- /dev/null +++ b/backend/.idea/.idea.SlideGenerator/.idea/copilot.data.migration.agent.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/backend/.idea/.idea.SlideGenerator/.idea/copilot.data.migration.ask.xml b/backend/.idea/.idea.SlideGenerator/.idea/copilot.data.migration.ask.xml new file mode 100644 index 00000000..7ef04e2e --- /dev/null +++ b/backend/.idea/.idea.SlideGenerator/.idea/copilot.data.migration.ask.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/backend/.idea/.idea.SlideGenerator/.idea/copilot.data.migration.ask2agent.xml b/backend/.idea/.idea.SlideGenerator/.idea/copilot.data.migration.ask2agent.xml new file mode 100644 index 00000000..1f2ea11e --- /dev/null +++ b/backend/.idea/.idea.SlideGenerator/.idea/copilot.data.migration.ask2agent.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/backend/.idea/.idea.SlideGenerator/.idea/discord.xml b/backend/.idea/.idea.SlideGenerator/.idea/discord.xml new file mode 100644 index 00000000..d8e95616 --- /dev/null +++ b/backend/.idea/.idea.SlideGenerator/.idea/discord.xml @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/backend/.idea/.idea.SlideGenerator/.idea/encodings.xml b/backend/.idea/.idea.SlideGenerator/.idea/encodings.xml new file mode 100644 index 00000000..df87cf95 --- /dev/null +++ b/backend/.idea/.idea.SlideGenerator/.idea/encodings.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/backend/.idea/.idea.SlideGenerator/.idea/indexLayout.xml b/backend/.idea/.idea.SlideGenerator/.idea/indexLayout.xml new file mode 100644 index 00000000..7b08163c --- /dev/null +++ b/backend/.idea/.idea.SlideGenerator/.idea/indexLayout.xml @@ -0,0 +1,8 @@ + + + + + + + + \ 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..7e8f0213 100644 --- a/backend/Directory.Build.props +++ b/backend/Directory.Build.props @@ -1,5 +1,9 @@ - - $(NoWarn);NU1902;NU1903;NETSDK1206 - + + $(NoWarn);NU1902;NU1903;NETSDK1206 + + + + + diff --git a/backend/Documents/en/architecture.md b/backend/Documents/en/architecture.md new file mode 100644 index 00000000..de2a10ca --- /dev/null +++ b/backend/Documents/en/architecture.md @@ -0,0 +1,56 @@ +# Architecture + +[🇻🇳 Vietnamese Version](../vi/architecture.md) + +## Overview + +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`. + +## Project Layout + +```mermaid +graph TD + 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 +``` + +### `SlideGenerator.Ipc` +- JSON-RPC host over stdio (`StreamJsonRpc`). +- Exposes methods: `system.health`, `slides.scan`, `excel.scan`, `jobs.*`. +- Emits `jobs.updated` notifications. + +### `SlideGenerator.Scan` +- Scan workflows for PPTX and Excel metadata. +- Returns DTO-compatible payloads (`SlideScanResult`, `SheetScanResult`). + +### `SlideGenerator.Generate` +- Generate request validation and mapping logic. +- Encapsulates feature-specific generation helpers. + +### `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. + +### 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). + +## Runtime Flow + +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: [Stdio JSON-RPC API](stdio-jsonrpc.md) diff --git a/backend/docs/en/configuration.md b/backend/Documents/en/configuration.md similarity index 100% rename from backend/docs/en/configuration.md rename to backend/Documents/en/configuration.md diff --git a/backend/docs/en/deployment.md b/backend/Documents/en/deployment.md similarity index 85% rename from backend/docs/en/deployment.md rename to backend/Documents/en/deployment.md index 31c938ff..d039e40f 100644 --- a/backend/docs/en/deployment.md +++ b/backend/Documents/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/Documents/en/development.md similarity index 62% rename from backend/docs/en/development.md rename to backend/Documents/en/development.md index 0afd501b..095bfcb6 100644 --- a/backend/docs/en/development.md +++ b/backend/Documents/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/Documents/en/face-detection.md b/backend/Documents/en/face-detection.md new file mode 100644 index 00000000..5f282702 --- /dev/null +++ b/backend/Documents/en/face-detection.md @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/Documents/en/job-system.md b/backend/Documents/en/job-system.md new file mode 100644 index 00000000..24485fea --- /dev/null +++ b/backend/Documents/en/job-system.md @@ -0,0 +1,91 @@ +# Job System + +[🇻🇳 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 grouping, pause/resume/cancel control, and crash recovery from SQLite checkpoints. + +## Concepts + +### Job Hierarchy + +The runtime executes a 3-level hierarchy: + +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. +- **Running:** Currently executing. +- **Paused:** Temporarily stopped by the user. State is preserved. +- **Completed:** Successfully completed. +- **Cancelled:** Stopped by user request. +- **Failed:** Failed due to an exception. + +### State Diagram + +```mermaid +stateDiagram-v2 + [*] --> Pending + 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 +``` + +## Persistence & Recovery + +Job state is persisted in SQLite: + +- `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:** 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 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 +- 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. +- **Step 2:** Process Replacements (Text & Images). +- **Step 3:** Render Slide. +- **Step 4:** Save to Output Path. + +### 4. Completion +- 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 top-level book execution; sheet concurrency is additionally bounded inside a book. + +Next: [Stdio JSON-RPC API](stdio-jsonrpc.md) diff --git a/backend/Documents/en/stdio-jsonrpc.md b/backend/Documents/en/stdio-jsonrpc.md new file mode 100644 index 00000000..9ec81a63 --- /dev/null +++ b/backend/Documents/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/Documents/en/usage.md similarity index 50% rename from backend/docs/en/usage.md rename to backend/Documents/en/usage.md index 43d77ee5..1bdf0f65 100644 --- a/backend/docs/en/usage.md +++ b/backend/Documents/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/Documents/vi/architecture.md similarity index 87% rename from backend/docs/vi/architecture.md rename to backend/Documents/vi/architecture.md index 8e443bb6..599d5784 100644 --- a/backend/docs/vi/architecture.md +++ b/backend/Documents/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/configuration.md b/backend/Documents/vi/configuration.md similarity index 100% rename from backend/docs/vi/configuration.md rename to backend/Documents/vi/configuration.md diff --git a/backend/docs/vi/deployment.md b/backend/Documents/vi/deployment.md similarity index 96% rename from backend/docs/vi/deployment.md rename to backend/Documents/vi/deployment.md index e0ef8952..d0a36f73 100644 --- a/backend/docs/vi/deployment.md +++ b/backend/Documents/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/Documents/vi/development.md similarity index 68% rename from backend/docs/vi/development.md rename to backend/Documents/vi/development.md index c381d085..26255c14 100644 --- a/backend/docs/vi/development.md +++ b/backend/Documents/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/Documents/vi/job-system.md similarity index 97% rename from backend/docs/vi/job-system.md rename to backend/Documents/vi/job-system.md index ff0da062..89c309e4 100644 --- a/backend/docs/vi/job-system.md +++ b/backend/Documents/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/usage.md b/backend/Documents/vi/usage.md similarity index 51% rename from backend/docs/vi/usage.md rename to backend/Documents/vi/usage.md index 8f6cf89e..0ab81c33 100644 --- a/backend/docs/vi/usage.md +++ b/backend/Documents/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/README.md b/backend/README.md index 4aecfc2b..1e79f1ca 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) @@ -33,7 +33,7 @@ This directory contains the backend source code, structured as a Clean Architect The backend is designed to be modular and testable. It strictly separates concerns between the core domain logic and external infrastructure. -👉 **Deep Dive:** [Architecture Documentation](docs/en/architecture.md) +👉 **Deep Dive:** [Architecture Documentation](Documents/en/architecture.md) ## Key Systems @@ -42,15 +42,15 @@ The backend is designed to be modular and testable. It strictly separates concer The heart of the application. It manages the lifecycle of slide generation tasks, from parsing Excel files to rendering PowerPoint slides. - **Features:** Parallel processing, Pause/Resume/Cancel capabilities, Crash recovery. -- **Learn more:** [Job System Documentation](docs/en/job-system.md) +- **Learn more:** [Job System Documentation](Documents/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](Documents/en/stdio-jsonrpc.md) ## Getting Started @@ -58,13 +58,13 @@ Real-time bi-directional communication with the Frontend. Customize server settings, job concurrency limits, and image processing parameters. -- **Guide:** [Configuration Guide](docs/en/configuration.md) +- **Guide:** [Configuration Guide](Documents/en/configuration.md) ### Usage How to run, interact, and troubleshoot the backend service. -- **Guide:** [Usage Guide](docs/en/usage.md) +- **Guide:** [Usage Guide](Documents/en/usage.md) ## Development Guide @@ -72,20 +72,20 @@ How to run, interact, and troubleshoot the backend service. Setup your environment, run the server locally, and run tests. -- **Guide:** [Development Guide](docs/en/development.md) +- **Guide:** [Development Guide](Documents/en/development.md) ### Deployment How to publish and deploy the backend for production (Windows/Linux). -- **Guide:** [Deployment Guide](docs/en/deployment.md) +- **Guide:** [Deployment Guide](Documents/en/deployment.md) ## Framework Library The core logic for slide manipulation is abstracted into a reusable framework. -- **Repository:** [SlideGenerator.Framework](../src/SlideGenerator.Framework/README.md) +- **Repository:** [SlideGenerator.Framework](../SlideGenerator.Framework/README.md) --- -[🇻🇳 Vietnamese Documentation](docs/vi) +[🇻🇳 Vietnamese Documentation](Documents/vi) diff --git a/backend/SlideGenerator.Application/Scanning/Models/Sheets/WorkbookInfo.cs b/backend/SlideGenerator.Application/Scanning/Models/Sheets/WorkbookInfo.cs new file mode 100644 index 00000000..abcf1396 --- /dev/null +++ b/backend/SlideGenerator.Application/Scanning/Models/Sheets/WorkbookInfo.cs @@ -0,0 +1,8 @@ +namespace SlideGenerator.Application.Scanning.Models.Sheets; + +/// +/// Represents worksheet scan response payload. +/// +/// Scanned spreadsheet file path. +/// Collection of worksheet scan items. +public sealed record WorkbookInfo(string FilePath, IReadOnlyList Sheets); \ No newline at end of file diff --git a/backend/SlideGenerator.Application/Scanning/Models/Sheets/WorksheetInfo.cs b/backend/SlideGenerator.Application/Scanning/Models/Sheets/WorksheetInfo.cs new file mode 100644 index 00000000..d277a8af --- /dev/null +++ b/backend/SlideGenerator.Application/Scanning/Models/Sheets/WorksheetInfo.cs @@ -0,0 +1,9 @@ +namespace SlideGenerator.Application.Scanning.Models.Sheets; + +/// +/// Represents scan result for a single worksheet. +/// +/// Worksheet name. +/// Detected header values. +/// Detected data row count. +public sealed record WorksheetInfo(string SheetName, IReadOnlyList Headers, int RecordCount); \ No newline at end of file diff --git a/backend/SlideGenerator.Application/Scanning/Models/Slides/PresentationInfo.cs b/backend/SlideGenerator.Application/Scanning/Models/Slides/PresentationInfo.cs new file mode 100644 index 00000000..67afae40 --- /dev/null +++ b/backend/SlideGenerator.Application/Scanning/Models/Slides/PresentationInfo.cs @@ -0,0 +1,8 @@ +namespace SlideGenerator.Application.Scanning.Models.Slides; + +/// +/// Represents slide scan response payload. +/// +/// Scanned presentation file path. +/// Collection of slide scan items. +public sealed record PresentationInfo(string FilePath, IReadOnlyList Slides); \ No newline at end of file diff --git a/backend/SlideGenerator.Application/Scanning/Models/Slides/SlideInfo.cs b/backend/SlideGenerator.Application/Scanning/Models/Slides/SlideInfo.cs new file mode 100644 index 00000000..eae643df --- /dev/null +++ b/backend/SlideGenerator.Application/Scanning/Models/Slides/SlideInfo.cs @@ -0,0 +1,12 @@ +namespace SlideGenerator.Application.Scanning.Models.Slides; + +/// +/// Represents scan result for a single slide. +/// +/// 1-based slide index. +/// Detected image-capable shape ids. +/// Detected text mustaches. +public sealed record SlideInfo( + int Index, + IReadOnlyList Mustaches, + IReadOnlyList ImageShapeIds); \ No newline at end of file diff --git a/backend/SlideGenerator.Application/Scanning/Services/ScanService.cs b/backend/SlideGenerator.Application/Scanning/Services/ScanService.cs new file mode 100644 index 00000000..14867446 --- /dev/null +++ b/backend/SlideGenerator.Application/Scanning/Services/ScanService.cs @@ -0,0 +1,73 @@ +using ClosedXML.Excel; +using DocumentFormat.OpenXml.Packaging; +using SlideGenerator.Application.Scanning.Models.Sheets; +using SlideGenerator.Application.Scanning.Models.Slides; +using SlideGenerator.Framework.Sheet.Services; +using SlideGenerator.Framework.Slide.Services; +using SlideGenerator.Framework.Slide.Services.Presentation; +using SlideGenerator.Framework.Slide.Services.Replacer; + +namespace SlideGenerator.Application.Scanning.Services; + +/// Reviewed by @thnhmai06 at 05/03/2026 +public sealed class ScanService +{ + public static async Task ScanPresentationAsync(string filePath) + { + filePath = Path.GetFullPath(filePath); + if (!File.Exists(filePath)) + throw new FileNotFoundException("Presentation file not found.", filePath); + + using var document = PresentationDocument.Open(filePath, false); + + var result = new List(); + foreach (var slidePart in document.EnumerateSlides()) + { + var mustaches = slidePart.ScanMustache() + .Select(m => m.Mustache) + .Distinct() + .ToList(); + var imageIds = slidePart.GetImageShapeIds().ToList(); + + result.Add(new SlideInfo(result.Count, mustaches, imageIds)); + } + + return await Task.FromResult(new PresentationInfo(filePath, result)); + } + + public static Task ScanWorkbookAsync(string filePath) + { + try + { + filePath = Path.GetFullPath(filePath); + if (!File.Exists(filePath)) + throw new FileNotFoundException("Workbook file not found.", filePath); + + using var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read); + using var workbook = new XLWorkbook(fs); + var sheets = new List(); + foreach (var sheet in workbook.Worksheets) + { + var contentRange = sheet.GetContentRange(); + if (contentRange == null) + { + sheets.Add(new WorksheetInfo(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 WorksheetInfo(sheet.Name, headers, recordCount)); + } + + return Task.FromResult(new WorkbookInfo(filePath, sheets)); + } + catch (Exception exception) + { + return Task.FromException(exception); + } + } +} \ No newline at end of file diff --git a/backend/SlideGenerator.Application/SlideGenerator.Application.csproj b/backend/SlideGenerator.Application/SlideGenerator.Application.csproj new file mode 100644 index 00000000..735543a2 --- /dev/null +++ b/backend/SlideGenerator.Application/SlideGenerator.Application.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + enable + enable + true + $(NoWarn);1591 + GPL-3.0-only + + + + + + + + + + + + + + + + diff --git a/backend/SlideGenerator.Domain/Configs/Contacts/IConfigProvider.cs b/backend/SlideGenerator.Domain/Configs/Contacts/IConfigProvider.cs new file mode 100644 index 00000000..c1b42989 --- /dev/null +++ b/backend/SlideGenerator.Domain/Configs/Contacts/IConfigProvider.cs @@ -0,0 +1,18 @@ +using SlideGenerator.Domain.Configs.Models; + +namespace SlideGenerator.Domain.Configs.Contacts; + +/// +/// Provides read-only access to the current configuration. +/// +/// +/// Reviewed by @thnhmai06 at 01/03/2026 00:36:50 GMT+7 +/// 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/SlideGenerator.Domain/Configs/Models/Config.DownloadConfig.cs b/backend/SlideGenerator.Domain/Configs/Models/Config.DownloadConfig.cs new file mode 100644 index 00000000..60240c50 --- /dev/null +++ b/backend/SlideGenerator.Domain/Configs/Models/Config.DownloadConfig.cs @@ -0,0 +1,52 @@ +using System.Net; + +namespace SlideGenerator.Domain.Configs.Models; + +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/SlideGenerator.Domain/Configs/Models/Config.ImageConfig.cs b/backend/SlideGenerator.Domain/Configs/Models/Config.ImageConfig.cs new file mode 100644 index 00000000..8c956712 --- /dev/null +++ b/backend/SlideGenerator.Domain/Configs/Models/Config.ImageConfig.cs @@ -0,0 +1,25 @@ +namespace SlideGenerator.Domain.Configs.Models; + +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/SlideGenerator.Domain/Configs/Models/Config.JobConfig.cs b/backend/SlideGenerator.Domain/Configs/Models/Config.JobConfig.cs new file mode 100644 index 00000000..e0e7090a --- /dev/null +++ b/backend/SlideGenerator.Domain/Configs/Models/Config.JobConfig.cs @@ -0,0 +1,12 @@ +namespace SlideGenerator.Domain.Configs.Models; + +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/SlideGenerator.Domain/Configs/Models/Config.cs b/backend/SlideGenerator.Domain/Configs/Models/Config.cs new file mode 100644 index 00000000..d845959c --- /dev/null +++ b/backend/SlideGenerator.Domain/Configs/Models/Config.cs @@ -0,0 +1,10 @@ +namespace SlideGenerator.Domain.Configs.Models; + +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/SlideGenerator.Domain/Configs/Services/ConfigManager.cs b/backend/SlideGenerator.Domain/Configs/Services/ConfigManager.cs new file mode 100644 index 00000000..053ba002 --- /dev/null +++ b/backend/SlideGenerator.Domain/Configs/Services/ConfigManager.cs @@ -0,0 +1,63 @@ +using SlideGenerator.Domain.Configs.Contacts; +using SlideGenerator.Domain.Configs.Models; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace SlideGenerator.Domain.Configs.Services; + +/// +/// Reviewed by @thnhmai06 at 01/03/2026 00:43:30 GMT+7 +/// +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() + { + try + { + var yaml = File.ReadAllText(ConfigFilePath); + var loaded = _deserializer.Deserialize(yaml); + + Current = loaded; + return true; + } + catch + { + // TODO: log the error + } + + return false; + } + + public bool Save() + { + try + { + Directory.CreateDirectory(Path.GetPathRoot(ConfigFilePath)!); + File.WriteAllText(ConfigFilePath, _serializer.Serialize(Current)); + return true; + } + catch + { + return false; + } + } + + public bool ResetToDefaults() + { + Current = new Config(); + return Save(); + } +} \ No newline at end of file diff --git a/backend/SlideGenerator.Domain/Download/Entities/DownloadFile.cs b/backend/SlideGenerator.Domain/Download/Entities/DownloadFile.cs new file mode 100644 index 00000000..e77a24ce --- /dev/null +++ b/backend/SlideGenerator.Domain/Download/Entities/DownloadFile.cs @@ -0,0 +1,111 @@ +using Downloader; + +namespace SlideGenerator.Domain.Download.Entities; + +/// +/// Represents a downloadable file with download state management and result caching. +/// +/// +/// This class encapsulates the download lifecycle: +/// 1. Temporary file (.crdownload) is used during download +/// 2. Upon completion, the file is renamed to final name with extension +/// 3. File bytes are cached in property +/// The class must be disposed to clean up the underlying service. +/// +/// Reviewed by @thnhmai06 at 04/03/2025 21:49:56 GMT+7 +public sealed class DownloadFile : IDisposable +{ + /// + /// Temporary file name (with .crdownload extension) used during download. + /// + private readonly string _tempFileWithExtensions; + + /// + /// The folder path where the file will be saved. + /// + public readonly string SaveFolder; + + /// + /// Gets the remote URL of the file to download. + /// + public readonly string Url; + + /// + /// Final file name (with actual extension) after download completion. + /// + private string _fileNameWithExtensions; + + /// + /// Initializes a new instance of the class. + /// + /// The remote URL of the file to download. + /// The folder path where the file will be saved. + /// Download configuration settings (timeout, retry, chunks, etc.). + /// + /// Optional custom file name without extension. If null, the server-provided name is used. + /// + public DownloadFile(string url, string saveFolder, DownloadConfiguration config, string? fileName = null) + { + Url = url; + SaveFolder = saveFolder; + _tempFileWithExtensions = (fileName ?? Guid.NewGuid().ToString()) + ".crdownload"; + _fileNameWithExtensions = _tempFileWithExtensions + ".bin"; + + Downloader = new DownloadService(config); + Downloader.DownloadStarted += (_, e) => + { + var ext = Path.GetExtension(e.FileName); + _fileNameWithExtensions = string.IsNullOrEmpty(fileName) ? e.FileName : fileName + ext; + }; + Downloader.DownloadFileCompleted += (_, _) => + { + if (File.Exists(TempFilePath)) + File.Move(TempFilePath, FilePath); + Result = File.ReadAllBytes(FilePath); + }; + } + + /// + /// Gets the full path to the temporary download file. + /// + private string TempFilePath => Path.Combine(SaveFolder, _tempFileWithExtensions); + + /// + /// Gets the full path to the final downloaded file. + /// The file may be non-existent until the download completes successfully. + /// + public string FilePath => Path.Combine(SaveFolder, _fileNameWithExtensions); + + /// + /// Gets the underlying downloader service for managing the download process. + /// + public DownloadService Downloader { get; } + + /// + /// Gets the downloaded file content as byte array. + /// Empty until completes successfully. + /// + public byte[] Result { get; private set; } = []; + + /// + /// Disposes the underlying downloader service and releases unmanaged resources. + /// + public void Dispose() + { + Downloader.Dispose(); + } + + /// + /// Downloads the file asynchronously from to the configured save folder. + /// + /// + /// The download uses a temporary file (.crdownload extension) during transfer. + /// Upon successful completion, the file is renamed to its final name and + /// is populated with the file bytes. + /// + /// A task representing the asynchronous download operation. + public async Task Download() + { + await Downloader.DownloadFileTaskAsync(Url, TempFilePath).ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/backend/SlideGenerator.Domain/Download/Services/ResolveService.cs b/backend/SlideGenerator.Domain/Download/Services/ResolveService.cs new file mode 100644 index 00000000..d5a28fb4 --- /dev/null +++ b/backend/SlideGenerator.Domain/Download/Services/ResolveService.cs @@ -0,0 +1,35 @@ +using SlideGenerator.Framework.Cloud.Services; + +namespace SlideGenerator.Domain.Download.Services; + +/// Review by @thnhmai06 at 04/03/2026 22:01:09 GMT+7 +public static class ResolveService +{ + public static Func> CheckImageUriFunc => CheckImageUri; + + /// + /// Resolves a remote URI and validates it with optional check function. + /// + /// The URI to resolve. + /// The HTTP client handler to use for requests. + /// The customized check function. + /// The resolved URL string. + /// The URI is not qualified by check function. + public static async Task ResolveUriAsync(Uri uri, HttpClientHandler handler, + Func>? checkUriFunc = null) + { + handler.AllowAutoRedirect = true; + using var httpClient = new HttpClient(handler); + uri = await CloudResolver.Instance.ResolveLinkAsync(uri, httpClient); + if (checkUriFunc != null && !await checkUriFunc(uri, httpClient)) + throw new InvalidOperationException($"The URI {uri} is not qualified check function."); + return uri; + } + + private static async Task CheckImageUri(Uri uri, HttpClient httpClient) + { + var response = await httpClient.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead); + return response.IsSuccessStatusCode + && response.Content.Headers.ContentType?.MediaType?.StartsWith("image/") == true; + } +} \ No newline at end of file diff --git a/backend/SlideGenerator.Domain/SlideGenerator.Domain.csproj b/backend/SlideGenerator.Domain/SlideGenerator.Domain.csproj new file mode 100644 index 00000000..a040e8ac --- /dev/null +++ b/backend/SlideGenerator.Domain/SlideGenerator.Domain.csproj @@ -0,0 +1,21 @@ + + + + net10.0 + enable + enable + true + $(NoWarn);1591 + GPL-3.0-only + + + + + + + + + + + + \ No newline at end of file diff --git a/backend/SlideGenerator.Domain/Tasks/Activities/LoadPresentation.cs b/backend/SlideGenerator.Domain/Tasks/Activities/LoadPresentation.cs new file mode 100644 index 00000000..6332414a --- /dev/null +++ b/backend/SlideGenerator.Domain/Tasks/Activities/LoadPresentation.cs @@ -0,0 +1,166 @@ +using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.Presentation; +using Elsa.Workflows; +using Elsa.Workflows.Activities; +using Elsa.Workflows.Models; +using SlideGenerator.Domain.Tasks.Models; + +namespace SlideGenerator.Domain.Tasks.Activities; + +/// +/// Workflow step to load a presentation template and keep only a specific slide. +/// Loads template from file into memory, removes all slides except the specified one, +/// changes document type if needed, and saves to output path. +/// +/// +/// +/// This workflow performs the following steps: +/// +/// +/// +/// +/// Validate Inputs: Checks that all required inputs (TemplatePath, SaveFolder, +/// FileName, TemplateIndex) are provided and valid. +/// +/// +/// +/// +/// Load Template: Reads the template file into a MemoryStream and opens it as +/// a PresentationDocument for in-memory editing. Supports both .pptx and .potx formats. +/// +/// +/// +/// +/// Remove Unwanted Slides: Iterates through all slides from end to start +/// (to avoid index shifting) and removes all slides except the one at TemplateIndex (1-based). +/// +/// +/// +/// +/// Change Document Type: Converts the document to the specified output format +/// using ChangeDocumentType based on FileExtension input. +/// +/// +/// +/// +/// Save to Output: Copies the modified presentation from memory to the output +/// file at the specified SaveFolder with the given FileName and FileExtension. +/// +/// +/// +/// +/// The entire process occurs in memory for efficiency - the template file is not modified, +/// and only the final result is written to disk at the output path. +/// +/// +public sealed class LoadPresentation : WorkflowBase +{ + /// + /// Input: Path to the template presentation file (.pptx or .potx). + /// + public Input TemplatePath { get; set; } = null!; + + /// + /// Input: Slide index (1-based) to keep in the output. All other slides will be removed. + /// + public Input TemplateIndex { get; set; } = null!; + + /// + /// Input: Folder where the output presentation will be saved. + /// + public Input SaveFolder { get; set; } = null!; + + /// + /// Input: Output file name without extension (e.g., "slide1"). + /// + public Input FileName { get; set; } = null!; + + /// + /// Input: Output file extension format (.pptx or .potx). + /// + public Input FileExtension { get; set; } = null!; + + /// + /// Output: Full file path to the saved presentation file. + /// + public Output FilePath { get; set; } = null!; + + /// + /// Loads template into memory, removes unwanted slides, changes document type, and saves to output path. + /// + protected override void Build(IWorkflowBuilder builder) + { + builder.Root = new Sequence + { + Activities = + { + new Inline(context => + { + // Get input values + var templatePath = context.Get(TemplatePath); + var saveFolder = context.Get(SaveFolder); + var fileName = context.Get(FileName); + var templateIndex = context.Get(TemplateIndex); + var outputExtension = context.Get(FileExtension).ToFileExtension(); + var outputType = context.Get(FileExtension).ToPresentationDocumentType(); + + // Validate inputs + if (string.IsNullOrEmpty(templatePath) || + string.IsNullOrEmpty(saveFolder) || + string.IsNullOrEmpty(fileName) || + templateIndex <= 0) + return; + + // Prepare output directory and path + Directory.CreateDirectory(saveFolder); + var outputPath = Path.Combine(saveFolder, $"{fileName}{outputExtension}"); + + // Step 1: Load template into memory stream + var bytes = File.ReadAllBytes(templatePath); + using var memoryStream = new MemoryStream(bytes); + using var doc = PresentationDocument.Open(memoryStream, true); + + // Step 2: Remove unwanted slides (keep only templateIndex) + var slideIdList = doc.PresentationPart?.Presentation?.SlideIdList; + if (slideIdList != null) + { + var slideIds = slideIdList.ChildElements.Cast().ToList(); + var slideCount = slideIds.Count; + // Remove from end to start to avoid index shifting + for (var index = slideCount; index >= 1; index--) + if (index != templateIndex) + slideIds[index - 1].Remove(); + } + + // Step 3: Change document type and save changes to memory stream + doc.ChangeDocumentType(outputType); + doc.Save(); + + // Step 4: Copy modified content to output file + memoryStream.Position = 0; + using (var outputStream = new FileStream(outputPath, FileMode.Create, FileAccess.Write)) + { + memoryStream.CopyTo(outputStream); + } + + // Set output value + context.Set(FilePath, outputPath); + }) + { + Name = "PreparePresentation" + }, + new Inline(context => + { + var filePath = context.Get(FilePath); + if (string.IsNullOrEmpty(filePath)) return; + + var doc = PresentationDocument.Open(filePath, true); + context.WorkflowExecutionContext.TransientProperties["Presentation"] = doc; + }) + { + Name = "LoadPresentation" + } + } + }; + } +} \ No newline at end of file diff --git a/backend/SlideGenerator.Domain/Tasks/Activities/LoadWorkbook.cs b/backend/SlideGenerator.Domain/Tasks/Activities/LoadWorkbook.cs new file mode 100644 index 00000000..1c14a43b --- /dev/null +++ b/backend/SlideGenerator.Domain/Tasks/Activities/LoadWorkbook.cs @@ -0,0 +1,98 @@ +using ClosedXML.Excel; +using Elsa.Workflows; +using Elsa.Workflows.Activities; +using Elsa.Workflows.Models; +using SlideGenerator.Framework.Sheet.Services; + +namespace SlideGenerator.Domain.Tasks.Activities; + +/// +/// Workflow step to open an Excel workbook and prepare a worksheet dictionary. +/// Opens the workbook file and creates a dictionary mapping sheet names to IXLWorksheet instances +/// for the specified list of sheets. +/// +/// +/// +/// This workflow performs the following steps: +/// +/// +/// +/// +/// Validate Inputs: Checks that WorkbookPath is provided and not empty. +/// +/// +/// +/// +/// Open Workbook: Calls to open +/// the Excel file in read-only mode. Supports both .xlsx and other Excel formats. +/// +/// +/// +/// +/// Build Worksheet Dictionary: Iterates through the SelectedSheets list +/// and retrieves each worksheet from the workbook. Creates a dictionary mapping sheet names +/// to IXLWorksheet instances. +/// +/// +/// +/// +/// Store in TransientProperties: Stores both the Workbook instance and +/// Worksheets dictionary in workflow context's TransientProperties for use by downstream tasks. +/// +/// +/// +/// +/// The Workbook instance is not persisted to workflow state - it exists only in runtime memory +/// (TransientProperties) to avoid serialization issues with large Excel objects. Downstream tasks +/// can retrieve these objects from the same TransientProperties. +/// +/// +public sealed class LoadWorkbook : WorkflowBase +{ + /// + /// Input: Full path to the Excel workbook file (.xlsx, .xls, etc.). + /// + public Input WorkbookPath { get; set; } = null!; + + /// + /// Input: List of worksheet names to load from the workbook. + /// + public Input> SelectedSheets { get; set; } = null!; + + /// + /// Loads the workbook and builds a worksheet dictionary stored in workflow context's TransientProperties. + /// + protected override void Build(IWorkflowBuilder builder) + { + builder.Root = new Inline(context => + { + // Step 1: Validate and get workbook path + var path = context.Get(WorkbookPath); + if (string.IsNullOrEmpty(path)) + return; + + // Step 2: Open workbook + var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); + var workbook = new XLWorkbook(fs); + + // Step 3: Get list of selected sheet names + var selectedSheets = context.Get(SelectedSheets); + if (selectedSheets == null) + return; + + // Step 4: Build worksheet dictionary by iterating selected sheets + var worksheetDict = new Dictionary(); + foreach (var sheetName in selectedSheets) + if (workbook.Worksheets.TryGetWorksheet(sheetName, out var worksheet)) + worksheetDict[sheetName] = worksheet; + + // Step 5: Store in TransientProperties (not persisted to state) + context.WorkflowExecutionContext.TransientProperties["WorkbookFs"] = fs; + context.WorkflowExecutionContext.TransientProperties["Workbook"] = workbook; + context.WorkflowExecutionContext.TransientProperties["Worksheets"] = worksheetDict; + }) + { + Name = "LoadWorkbook" + }; + } +} \ No newline at end of file diff --git a/backend/SlideGenerator.Domain/Tasks/Models/GenerateRequest.cs b/backend/SlideGenerator.Domain/Tasks/Models/GenerateRequest.cs new file mode 100644 index 00000000..f2fdf40e --- /dev/null +++ b/backend/SlideGenerator.Domain/Tasks/Models/GenerateRequest.cs @@ -0,0 +1,19 @@ +namespace SlideGenerator.Domain.Tasks.Models; + +/// +/// Represents a generation request payload consumed by endpoint. +/// +/// Workbook file path. +/// Sheet-to-template-slide mapping. +/// Text replacement bindings. +/// Image replacement bindings. +/// Save folder for generated presentations. +/// +/// Reviewed by @thnhmai06 at 01/03/2026 00:58:42 GMT+7 +/// +public sealed record GenerateRequest( + string WorkbookPath, + IReadOnlyDictionary SheetToSlideMap, // sheet name -> slide + IReadOnlyList TextConfigs, + IReadOnlyList ImageConfigs, + string SaveFolder); \ No newline at end of file diff --git a/backend/SlideGenerator.Domain/Tasks/Models/ImageConfig.cs b/backend/SlideGenerator.Domain/Tasks/Models/ImageConfig.cs new file mode 100644 index 00000000..ac2345a8 --- /dev/null +++ b/backend/SlideGenerator.Domain/Tasks/Models/ImageConfig.cs @@ -0,0 +1,11 @@ +using SlideGenerator.Framework.Image.Models.Roi; + +namespace SlideGenerator.Domain.Tasks.Models; + +/// +/// Represents an image binding configuration for replacement. +/// +/// The shape wants to replace. +/// Candidate columns used to resolve image source value. +/// ROI mode used for image crop and placement. +public sealed record ImageConfig(ShapeInfo Shape, IReadOnlyList Columns, RoiType RoiType); \ No newline at end of file diff --git a/backend/SlideGenerator.Domain/Tasks/Models/OutputExtension.cs b/backend/SlideGenerator.Domain/Tasks/Models/OutputExtension.cs new file mode 100644 index 00000000..0a360d66 --- /dev/null +++ b/backend/SlideGenerator.Domain/Tasks/Models/OutputExtension.cs @@ -0,0 +1,35 @@ +using DocumentFormat.OpenXml; + +namespace SlideGenerator.Domain.Tasks.Models; + +public enum OutputExtension +{ + Potx, + Pptx +} + +public static class OutputExtensionExtensions +{ + extension(OutputExtension extension) + { + public string ToFileExtension() + { + return extension switch + { + OutputExtension.Potx => ".potx", + OutputExtension.Pptx => ".pptx", + _ => throw new ArgumentOutOfRangeException(nameof(extension), extension, null) + }; + } + + public PresentationDocumentType ToPresentationDocumentType() + { + return extension switch + { + OutputExtension.Potx => PresentationDocumentType.Template, + OutputExtension.Pptx => PresentationDocumentType.Presentation, + _ => throw new ArgumentOutOfRangeException(nameof(extension), extension, null) + }; + } + } +} \ No newline at end of file diff --git a/backend/SlideGenerator.Domain/Tasks/Models/ShapeInfo.cs b/backend/SlideGenerator.Domain/Tasks/Models/ShapeInfo.cs new file mode 100644 index 00000000..410c8419 --- /dev/null +++ b/backend/SlideGenerator.Domain/Tasks/Models/ShapeInfo.cs @@ -0,0 +1,8 @@ +namespace SlideGenerator.Domain.Tasks.Models; + +/// +/// Represents a shape source. +/// +/// The slide contains this shape. +/// The ID of shape in Slide. +public record ShapeInfo(SlideInfo Slide, uint Id); \ No newline at end of file diff --git a/backend/SlideGenerator.Domain/Tasks/Models/SlideInfo.cs b/backend/SlideGenerator.Domain/Tasks/Models/SlideInfo.cs new file mode 100644 index 00000000..72a6c00b --- /dev/null +++ b/backend/SlideGenerator.Domain/Tasks/Models/SlideInfo.cs @@ -0,0 +1,8 @@ +namespace SlideGenerator.Domain.Tasks.Models; + +/// +/// Represents a slide source. +/// +/// The file path to presentation file contains this slide. +/// 1-based slide index in template presentation. +public sealed record SlideInfo(string FilePath, int Index); \ No newline at end of file diff --git a/backend/SlideGenerator.Domain/Tasks/Models/TaskControl.cs b/backend/SlideGenerator.Domain/Tasks/Models/TaskControl.cs new file mode 100644 index 00000000..a18610f5 --- /dev/null +++ b/backend/SlideGenerator.Domain/Tasks/Models/TaskControl.cs @@ -0,0 +1,22 @@ +namespace SlideGenerator.Domain.Tasks.Models; + +/// +/// Control actions that can be applied to a job. +/// +public enum TaskControl +{ + /// + /// Pause the job at the next checkpoint. + /// + Pause, + + /// + /// Resume a paused job. + /// + Resume, + + /// + /// Cancel the job immediately. + /// + Cancel +} \ No newline at end of file diff --git a/backend/SlideGenerator.Domain/Tasks/Models/TaskStatus.cs b/backend/SlideGenerator.Domain/Tasks/Models/TaskStatus.cs new file mode 100644 index 00000000..7d94eb08 --- /dev/null +++ b/backend/SlideGenerator.Domain/Tasks/Models/TaskStatus.cs @@ -0,0 +1,37 @@ +namespace SlideGenerator.Domain.Tasks.Models; + +/// +/// Status of a job (group or sheet level). +/// +public enum TaskStatus +{ + /// + /// Job is waiting to start. + /// + Pending, + + /// + /// Job is actively processing. + /// + Running, + + /// + /// Job is temporarily paused. + /// + Paused, + + /// + /// Job completed successfully. + /// + Completed, + + /// + /// Job failed with error. + /// + Error, + + /// + /// Job was cancelled by user. + /// + Cancelled +} \ No newline at end of file diff --git a/backend/SlideGenerator.Domain/Tasks/Models/TextConfig.cs b/backend/SlideGenerator.Domain/Tasks/Models/TextConfig.cs new file mode 100644 index 00000000..8151815c --- /dev/null +++ b/backend/SlideGenerator.Domain/Tasks/Models/TextConfig.cs @@ -0,0 +1,8 @@ +namespace SlideGenerator.Domain.Tasks.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/SlideGenerator.Framework b/backend/SlideGenerator.Framework new file mode 160000 index 00000000..c712e17f --- /dev/null +++ b/backend/SlideGenerator.Framework @@ -0,0 +1 @@ +Subproject commit c712e17f826671f12e9a61bcc05c1370ed5118c2 diff --git a/backend/SlideGenerator.Ipc/Contracts/Requests/JobIdRequest.cs b/backend/SlideGenerator.Ipc/Contracts/Requests/JobIdRequest.cs new file mode 100644 index 00000000..f7916441 --- /dev/null +++ b/backend/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/SlideGenerator.Ipc/Contracts/Requests/ScanFileRequest.cs b/backend/SlideGenerator.Ipc/Contracts/Requests/ScanFileRequest.cs new file mode 100644 index 00000000..b38aa13c --- /dev/null +++ b/backend/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/SlideGenerator.Ipc/Dockerfile similarity index 63% rename from backend/src/SlideGenerator.Presentation/Dockerfile rename to backend/SlideGenerator.Ipc/Dockerfile index 9421c543..1ec80159 100644 --- a/backend/src/SlideGenerator.Presentation/Dockerfile +++ b/backend/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/SlideGenerator.Ipc/Endpoints/RpcEndpoint.Configs.cs b/backend/SlideGenerator.Ipc/Endpoints/RpcEndpoint.Configs.cs new file mode 100644 index 00000000..d7681f4d --- /dev/null +++ b/backend/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 + }; + } +} \ No newline at end of file diff --git a/backend/SlideGenerator.Ipc/Endpoints/RpcEndpoint.Excel.cs b/backend/SlideGenerator.Ipc/Endpoints/RpcEndpoint.Excel.cs new file mode 100644 index 00000000..f1783e0d --- /dev/null +++ b/backend/SlideGenerator.Ipc/Endpoints/RpcEndpoint.Excel.cs @@ -0,0 +1,17 @@ +using SlideGenerator.Ipc.Contracts.Requests; +using SlideGenerator.Application.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/SlideGenerator.Ipc/Endpoints/RpcEndpoint.Jobs.cs b/backend/SlideGenerator.Ipc/Endpoints/RpcEndpoint.Jobs.cs new file mode 100644 index 00000000..f207c120 --- /dev/null +++ b/backend/SlideGenerator.Ipc/Endpoints/RpcEndpoint.Jobs.cs @@ -0,0 +1,63 @@ +using SlideGenerator.Ipc.Contracts.Requests; +using SlideGenerator.Domain.Tasks.Models; +using StreamJsonRpc; + +namespace SlideGenerator.Ipc.Endpoints; + +public sealed partial class RpcEndpoint +{ + [JsonRpcMethod("jobs.create")] + public async Task CreateJobAsync(GenerateRequest 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, TaskControl.Pause, cancellationToken); + return new { ok = true }; + } + + [JsonRpcMethod("jobs.resume")] + public async Task ResumeJobAsync(JobIdRequest request, CancellationToken cancellationToken) + { + await ControlJobAsync(request, TaskControl.Resume, cancellationToken); + return new { ok = true }; + } + + [JsonRpcMethod("jobs.cancel")] + public async Task CancelJobAsync(JobIdRequest request, CancellationToken cancellationToken) + { + await ControlJobAsync(request, TaskControl.Cancel, cancellationToken); + return new { ok = true }; + } + + private async Task ControlJobAsync(JobIdRequest request, TaskControl 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/SlideGenerator.Ipc/Endpoints/RpcEndpoint.Slides.cs b/backend/SlideGenerator.Ipc/Endpoints/RpcEndpoint.Slides.cs new file mode 100644 index 00000000..b8404513 --- /dev/null +++ b/backend/SlideGenerator.Ipc/Endpoints/RpcEndpoint.Slides.cs @@ -0,0 +1,18 @@ +using SlideGenerator.Ipc.Contracts.Requests; +using SlideGenerator.Application.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/SlideGenerator.Ipc/Endpoints/RpcEndpoint.System.cs b/backend/SlideGenerator.Ipc/Endpoints/RpcEndpoint.System.cs new file mode 100644 index 00000000..77ea0e2d --- /dev/null +++ b/backend/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/SlideGenerator.Ipc/Endpoints/RpcEndpoint.cs b/backend/SlideGenerator.Ipc/Endpoints/RpcEndpoint.cs new file mode 100644 index 00000000..b2331fdd --- /dev/null +++ b/backend/SlideGenerator.Ipc/Endpoints/RpcEndpoint.cs @@ -0,0 +1,42 @@ +using SlideGenerator.Application; +using SlideGenerator.Domain.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(JobSnapshot 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/SlideGenerator.Ipc/Program.cs b/backend/SlideGenerator.Ipc/Program.cs new file mode 100644 index 00000000..a67fbbd1 --- /dev/null +++ b/backend/SlideGenerator.Ipc/Program.cs @@ -0,0 +1,71 @@ +using Elsa.EntityFrameworkCore.Extensions; +using Elsa.EntityFrameworkCore.Modules.Management; +using Elsa.EntityFrameworkCore.Modules.Runtime; +using Elsa.Extensions; +using Microsoft.Extensions.DependencyInjection; +using SlideGenerator.Framework.Features.Image.Contracts; +using SlideGenerator.Framework.Features.Image.Services; +using SlideGenerator.Ipc.Endpoints; +using SlideGenerator.Application; +using SlideGenerator.Application.Generating.Services; +using SlideGenerator.Domain.Configs.Contracts; +using SlideGenerator.Domain.Configs.Entities; +using SlideGenerator.Domain.Configs.Services; +using StreamJsonRpc; + +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(serviceProvider => + serviceProvider.GetRequiredService()); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + return services; + } +} \ No newline at end of file diff --git a/backend/SlideGenerator.Ipc/Properties/launchSettings.json b/backend/SlideGenerator.Ipc/Properties/launchSettings.json new file mode 100644 index 00000000..f6b35971 --- /dev/null +++ b/backend/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/SlideGenerator.Ipc/SlideGenerator.Ipc.csproj b/backend/SlideGenerator.Ipc/SlideGenerator.Ipc.csproj new file mode 100644 index 00000000..0dcf74ce --- /dev/null +++ b/backend/SlideGenerator.Ipc/SlideGenerator.Ipc.csproj @@ -0,0 +1,64 @@ + + + + net10.0 + enable + enable + Exe + true + GPL-3.0-only + $(NoWarn);1591 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/backend/src/SlideGenerator.Presentation/appsettings.Development.json b/backend/SlideGenerator.Ipc/appsettings.Development.json similarity index 100% rename from backend/src/SlideGenerator.Presentation/appsettings.Development.json rename to backend/SlideGenerator.Ipc/appsettings.Development.json diff --git a/backend/src/SlideGenerator.Presentation/appsettings.json b/backend/SlideGenerator.Ipc/appsettings.json similarity index 100% rename from backend/src/SlideGenerator.Presentation/appsettings.json rename to backend/SlideGenerator.Ipc/appsettings.json diff --git a/backend/SlideGenerator.slnx b/backend/SlideGenerator.slnx index 28fc9562..c314ffff 100644 --- a/backend/SlideGenerator.slnx +++ b/backend/SlideGenerator.slnx @@ -1,8 +1,7 @@ - - - - - - + + + + diff --git a/backend/construction.md b/backend/construction.md new file mode 100644 index 00000000..20625fa1 --- /dev/null +++ b/backend/construction.md @@ -0,0 +1,57 @@ +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.Framework`: thư viện core logic dùng lại (slide/sheet/image/cloud services) - độc lập, có thể publish NuGet +- `SlideGenerator.Features`: domain features (Configs, Jobs orchestration, Slides/Sheets models) +- `SlideGenerator.Services`: application services (ScanService, GenerateService, DownloadService, validation/model manager) +- `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. +- `Features` chứa domain entities/models và Jobs orchestration. +- `Services` chứa các service runtime (generate, scan, download, validation). +- 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` (trong `Features.Configs`). +- 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` ở `Services.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` (trong `Services.Generating`, dùng 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` hoặc `Services.Generating.Models`. +- Endpoint chỉ validate input và gọi `BackendService` (trong `Features.Jobs`). + +## 7) Scanning contract +- `ScanService` (trong `Services`) 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 `Features.Slides.Models` / `Features.Sheets.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 deleted file mode 100644 index 6027e2e0..00000000 --- a/backend/docs/en/architecture.md +++ /dev/null @@ -1,68 +0,0 @@ -# Architecture - -[🇻🇳 Vietnamese Version](../vi/architecture.md) - -## 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. - -## Layered Architecture - -The solution is divided into four concentric layers: - -```mermaid -graph TD - Presentation --> Application - Application --> Domain - Infrastructure --> Application - Infrastructure --> Domain - Presentation --> Infrastructure -``` - -### 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. - -### 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). - -### 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. - -### 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. - -## Key Runtime Components - -### Job Execution 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`. - -Next: [SignalR API](signalr.md) diff --git a/backend/docs/en/job-system.md b/backend/docs/en/job-system.md deleted file mode 100644 index 5246e729..00000000 --- a/backend/docs/en/job-system.md +++ /dev/null @@ -1,90 +0,0 @@ -# Job System - -[🇻🇳 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. - -## Concepts - -### Job Hierarchy - -The system uses a composite pattern to manage jobs: - -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. - -### Job States - -A job transitions through the following states: - -- **Pending:** Queued and waiting for execution resources. -- **Processing:** Currently running (parsing data or generating slides). -- **Paused:** Temporarily stopped by the user. State is preserved. -- **Done:** Successfully completed. -- **Cancelled:** Stopped by user request. -- **Error:** 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 - Paused --> Cancelled: User Cancel - Pending --> Cancelled: User Cancel -``` - -## Collections & Persistence - -The `JobManager` orchestrates jobs across two primary collections: - -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`. - -### 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`. - -## 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**. - -### 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. - -### 3. Processing -- **Step 1:** Load Template & Data. -- **Step 2:** Process Replacements (Text & Images). -- **Step 3:** Render Slide. -- **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**. - -## 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. - -Next: [SignalR API](signalr.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/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/global.json b/backend/global.json new file mode 100644 index 00000000..a11f48e1 --- /dev/null +++ b/backend/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "10.0.0", + "rollForward": "latestMajor", + "allowPrerelease": true + } +} \ No newline at end of file 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.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 deleted file mode 160000 index e6d033ea..00000000 --- a/backend/src/SlideGenerator.Framework +++ /dev/null @@ -1 +0,0 @@ -Subproject commit e6d033ea965e06ac4721197da49a483313733f71 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.Infrastructure/SlideGenerator.Infrastructure.csproj b/backend/src/SlideGenerator.Infrastructure/SlideGenerator.Infrastructure.csproj deleted file mode 100644 index 5dfb90a5..00000000 --- a/backend/src/SlideGenerator.Infrastructure/SlideGenerator.Infrastructure.csproj +++ /dev/null @@ -1,39 +0,0 @@ - - - - net10.0 - enable - enable - true - $(NoWarn);1591 - win-x64 - GPL-3.0-only - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 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/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')} />