diff --git a/.codegraph/.gitignore b/.codegraph/.gitignore new file mode 100644 index 00000000..d20c0fe4 --- /dev/null +++ b/.codegraph/.gitignore @@ -0,0 +1,5 @@ +# CodeGraph data files — local to each machine, not for committing. +# Ignore everything in .codegraph/ except this file itself, so transient +# files (the database, daemon.pid, sockets, logs) never show up in git. +* +!.gitignore diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..b7fd9e9d --- /dev/null +++ b/.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/.env.example b/.env.example new file mode 100644 index 00000000..6a8167c8 --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +SYNCFUSION_LICENSE_KEY= # Syncfustion License Key \ No newline at end of file diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index ebefee75..9d2fa0e9 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,6 +1,6 @@ # These are supported funding model platforms -github: [thnhmai06] +github: [ thnhmai06 ] patreon: # Replace with a single Patreon username open_collective: # Replace with a single Open Collective username ko_fi: # Replace with a single Ko-fi username diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml new file mode 100644 index 00000000..cb4d9d14 --- /dev/null +++ b/.github/codeql/codeql-config.yml @@ -0,0 +1,4 @@ +name: "SlideGenerator CodeQL Config" +paths-ignore: + - "tests/**" + - "scripts/**" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index 9cc826da..00000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,90 +0,0 @@ -name: Build & Test - -on: - push: - branches: [main] - pull_request: - workflow_dispatch: - -permissions: - contents: read - checks: write - -jobs: - backend-tests: - if: ${{ !((github.event_name == 'push' && startsWith(github.event.head_commit.message || '', '[skip ci]')) || (github.event_name == 'pull_request' && startsWith(github.event.pull_request.title || '', '[skip ci]'))) }} - name: Backend tests - runs-on: windows-latest - steps: - - uses: actions/checkout@v4 - with: - submodules: recursive - - - name: Install Task - uses: arduino/setup-task@v2 - with: - version: 3.x - repo-token: ${{ secrets.GITHUB_TOKEN }} - - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: "10.0.x" - - - name: Run Backend Tests - run: task test:backend - - - name: Upload backend test results - if: always() - uses: actions/upload-artifact@v4 - with: - name: backend-test-results - path: backend/TestResults/*.trx - - - name: Publish backend test report - if: always() - uses: dorny/test-reporter@v1 - with: - name: Backend Tests - path: backend/TestResults/*.trx - reporter: dotnet-trx - - frontend-tests: - if: ${{ !((github.event_name == 'push' && startsWith(github.event.head_commit.message || '', '[skip ci]')) || (github.event_name == 'pull_request' && startsWith(github.event.pull_request.title || '', '[skip ci]'))) }} - name: Frontend tests - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - submodules: recursive - - - name: Install Task - uses: arduino/setup-task@v2 - with: - version: 3.x - repo-token: ${{ secrets.GITHUB_TOKEN }} - - - name: Setup Node - uses: actions/setup-node@v4 - with: - node-version: "20.x" - cache: npm - cache-dependency-path: frontend/package-lock.json - - - name: Run Frontend Tests - run: task test:frontend - - - name: Upload frontend test results - if: always() - uses: actions/upload-artifact@v4 - with: - name: frontend-test-results - path: frontend/test-results/junit.xml - - - name: Publish frontend test report - if: always() - uses: dorny/test-reporter@v1 - with: - name: Frontend Tests - path: frontend/test-results/junit.xml - reporter: java-junit diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..1282b48d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,148 @@ +name: "CI" + +on: + workflow_dispatch: + push: + paths: + - "src/**" + - "tests/**" + - "Directory.Build.props" + - "Directory.Packages.props" + - "global.json" + - "nuget.config" + - "*.slnx" + - "Taskfile.yml" + - ".github/workflows/ci.yml" + pull_request: + types: [ opened, synchronize, reopened ] + paths: + - "src/**" + - "tests/**" + - "Directory.Build.props" + - "Directory.Packages.props" + - "global.json" + - "nuget.config" + - "*.slnx" + - "Taskfile.yml" + - ".github/workflows/ci.yml" + release: + types: [ published ] + +permissions: + contents: read + actions: read + packages: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +env: + DOTNET_NOLOGO: "true" + DOTNET_CLI_TELEMETRY_OPTOUT: "1" + SYNCFUSION_LICENSE_KEY: ${{ secrets.SYNCFUSION_LICENSE_KEY || 'empty' }} + GITHUB_USERNAME: ${{ github.actor }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages + +jobs: + ci: + name: ${{ matrix.name }} + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - name: windows-x64 + runner: windows-latest + - name: windows-arm64 + runner: windows-11-arm + - name: linux-x64 + runner: ubuntu-24.04 + - name: linux-arm64 + runner: ubuntu-24.04-arm + - name: macos-x64 + runner: macos-26-intel + - name: macos-arm64 + runner: macos-26 + + defaults: + run: + shell: pwsh + + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + global-json-file: global.json + + - name: Cache NuGet packages + uses: actions/cache@v5 + with: + path: ${{ github.workspace }}/.nuget/packages + key: nuget-${{ matrix.name }}-${{ hashFiles('**/*.csproj', 'global.json', 'nuget.config') }} + restore-keys: nuget-${{ matrix.name }}- + + - name: Restore + run: dotnet restore SlideGenerator.slnx + + - name: Fetch face fixtures + # Generator.Tests copies tests/fixtures/faces/single/f_0.jpg at build time (conditional on the file + # existing — see SlideGenerator.Generator.Tests.csproj), and its integration test uses it as the + # fallback crop image. The faces are not committed; FaceDatasetFixture (Image.Tests) downloads them + # at test time, which is too late for the Generator build. Seed just f_0.jpg here (same source and + # row index the fixture uses). Best-effort: if the network is down the Generator integration test + # simply skips, same as before. + run: | + $dir = "tests/fixtures/faces/single" + New-Item -ItemType Directory -Force -Path $dir | Out-Null + $dest = Join-Path $dir "f_0.jpg" + if (Test-Path $dest) { exit 0 } + try { + $rows = Invoke-RestMethod "https://datasets-server.huggingface.co/rows?dataset=eurecom-ds%2Fceleba-hq&config=default&split=train&offset=0&length=1" + Invoke-WebRequest $rows.rows[0].row.image.src -OutFile $dest + } catch { + Write-Host "::warning::Face fixture download skipped; Generator integration test will skip" + exit 0 + } + + - name: Build + run: dotnet build SlideGenerator.slnx --no-restore + + - name: Run tests + run: | + $rc = 0 + Get-ChildItem -Path tests -Filter *.csproj -Recurse | Sort-Object Name | ForEach-Object { + $name = $_.BaseName + Write-Host "::group::$name" + dotnet test $_.FullName --report-trx --report-trx-filename "$name.trx" --results-directory "TestResults/$name" + if ($LASTEXITCODE -ne 0) { $rc = $LASTEXITCODE } + Write-Host "::endgroup::" + } + exit $rc + + - name: Collect test outputs + if: always() + run: | + Get-ChildItem -Path tests -Filter *.pptx -Recurse -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -replace '\\','/' -match '/fixtures/output/' } | + ForEach-Object { + $rel = ($_.FullName -replace '\\','/') -replace '^.*/tests/', '' + $proj = $rel.Split('/')[0] + $dest = "TestResults/$proj/output" + New-Item -ItemType Directory -Force -Path $dest | Out-Null + Copy-Item $_.FullName -Destination $dest + } + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v7 + with: + name: test-results-${{ matrix.name }} + path: TestResults + if-no-files-found: warn + overwrite: true + retention-days: 14 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000..b695c6be --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,43 @@ +name: "CodeQL" + +on: + push: + branches: [ develop ] + pull_request: + branches: [ develop ] + schedule: + - cron: "0 3 * * 1" # Monday 03:00 UTC weekly + workflow_dispatch: + +permissions: + contents: read + security-events: write + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - language: csharp + build-mode: none + - language: actions + build-mode: none + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + config-file: ./.github/codeql/codeql-config.yml + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/qodana.yml b/.github/workflows/qodana.yml new file mode 100644 index 00000000..3a036590 --- /dev/null +++ b/.github/workflows/qodana.yml @@ -0,0 +1,60 @@ +name: "Qodana" + +on: + workflow_dispatch: + pull_request: + branches: [ main, develop ] + push: + branches: [ main, develop ] + paths: + - "src/**" + - "Directory.Build.props" + - "Directory.Packages.props" + - "global.json" + - "*.slnx" + - ".github/workflows/qodana.yml" + +permissions: + contents: write + pull-requests: write + checks: write + packages: read + +jobs: + qodana: + name: Qodana Scan + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + fetch-depth: 0 + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + global-json-file: global.json + + - name: Configure NuGet credentials + run: | + dotnet nuget update source SlideGenerator.OpenCvSharp \ + --username "${{ github.actor }}" \ + --password "${{ secrets.GITHUB_TOKEN }}" \ + --store-password-in-clear-text \ + --configfile nuget.config + + - name: Qodana Scan + uses: JetBrains/qodana-action@v2026.1 + with: + args: --project-dir . + results-dir: ${{ runner.temp }}/qodana/results + cache-dir: ${{ runner.temp }}/qodana/cache + upload-result: true + artifact-name: qodana-report + pr-mode: ${{ github.event_name == 'pull_request' }} + post-pr-comment: true + github-token: ${{ github.token }} + env: + QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index b3d6e5f2..00000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,68 +0,0 @@ -name: Build Release - -on: - push: - tags: - - "v*" - release: - types: [published] - -permissions: - contents: write - packages: write - -jobs: - build: - name: Build (${{ matrix.os }}) - runs-on: ${{ matrix.os }} - - strategy: - fail-fast: false - matrix: - os: - - windows-latest - - ubuntu-latest - - steps: - - uses: actions/checkout@v4 - with: - submodules: recursive - - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: "10.0.x" - - - name: Setup Node - uses: actions/setup-node@v4 - with: - node-version: "20.x" - cache: npm - cache-dependency-path: frontend/package-lock.json - - - name: Publish backend - shell: bash - run: | - if [[ "$RUNNER_OS" == "Windows" ]]; then - RID=win-x64 - elif [[ "$RUNNER_OS" == "macOS" ]]; then - RID=osx-x64 - else - RID=linux-x64 - fi - - dotnet publish backend/src/SlideGenerator.Presentation/SlideGenerator.Presentation.csproj \ - -c Release \ - -r $RID \ - --self-contained false \ - -o frontend/backend - - - name: Install frontend deps - working-directory: frontend - run: npm ci - - - name: Build & Publish Electron - working-directory: frontend - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: npm run build diff --git a/.github/workflows/sponsors.yml b/.github/workflows/sponsors.yml new file mode 100644 index 00000000..cdc9d2ff --- /dev/null +++ b/.github/workflows/sponsors.yml @@ -0,0 +1,88 @@ +name: Publish sponsors + +# Publishes sponsors.json to the "data" branch — the About page's Supporters list fetches it from +# raw.githubusercontent.com (see AboutDataService.GetSupportersAsync). Reads every GitHub profile listed +# under FUNDING.yml's `github:` key (currently just thnhmai06) and merges their sponsors into one list. +# +# Requires a repo secret SPONSORS_PAT: a GitHub personal access token with the `read:user` scope, created by +# (or with read access to) every profile listed in FUNDING.yml's `github:` key — plan §5.7-Q5. Until that +# secret is added, this workflow still runs but writes an empty sponsors.json (the About page already shows +# an EmptyState for that case, not an error). + +on: + schedule: + - cron: "0 6 * * 1" # weekly, Monday 06:00 UTC + workflow_dispatch: {} + +permissions: + contents: write + +jobs: + publish-sponsors: + runs-on: ubuntu-latest + steps: + - name: Checkout main (to read FUNDING.yml) + uses: actions/checkout@v4 + + - name: Extract sponsor profiles from FUNDING.yml + id: profiles + run: | + # FUNDING.yml's `github:` key is a flow-style single-line list today, e.g. `github: [ thnhmai06 ]`. + # Only that flow-style form is supported here — a block-style (multi-line "- name") list would + # need a real YAML parser instead of this grep/sed one-liner; upgrade this step if FUNDING.yml + # ever switches format. + logins=$(grep '^github:' .github/FUNDING.yml | sed -E 's/^github:\s*\[(.*)\]/\1/' | tr ',' '\n' | tr -d ' ' | grep -v '^$' || true) + echo "logins<> "$GITHUB_OUTPUT" + echo "$logins" >> "$GITHUB_OUTPUT" + echo "SPONSORS_EOF" >> "$GITHUB_OUTPUT" + + - name: Query sponsors via GraphQL + env: + GH_TOKEN: ${{ secrets.SPONSORS_PAT }} + LOGINS: ${{ steps.profiles.outputs.logins }} + run: | + all="[]" + if [ -z "${GH_TOKEN}" ]; then + echo "SPONSORS_PAT secret not set — writing an empty sponsors.json until it's added." + else + while IFS= read -r login; do + [ -z "$login" ] && continue + resp=$(gh api graphql -f query=' + query($login: String!) { + user(login: $login) { + sponsorshipsAsMaintainer(first: 100, includePrivate: false) { + nodes { + sponsorEntity { + ... on User { login avatarUrl url } + ... on Organization { login avatarUrl url } + } + } + } + } + }' -f login="$login") + entities=$(echo "$resp" | jq '[.data.user.sponsorshipsAsMaintainer.nodes[].sponsorEntity | {login, avatarUrl, profileUrl: .url}]') + all=$(jq -n --argjson a "$all" --argjson b "$entities" '$a + $b') + done <<< "$LOGINS" + all=$(echo "$all" | jq 'unique_by(.login)') + fi + echo "$all" > /tmp/sponsors.json + + - name: Publish sponsors.json to the data branch + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git fetch origin data || true + if git show-ref --verify --quiet refs/remotes/origin/data; then + git checkout -B data origin/data + else + git checkout --orphan data + git rm -rf . > /dev/null 2>&1 || true + fi + cp /tmp/sponsors.json sponsors.json + git add sponsors.json + if git diff --cached --quiet; then + echo "sponsors.json unchanged — skipping commit." + else + git commit -m "chore: update sponsors.json" + git push origin data + fi diff --git a/.gitignore b/.gitignore index 93ad56c7..2222a99c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,123 @@ +# Created by https://www.toptal.com/developers/gitignore/api/jetbrains+all,visualstudio,visualstudiocode,dotnetcore +# Edit at https://www.toptal.com/developers/gitignore?templates=jetbrains+all,visualstudio,visualstudiocode,dotnetcore + +### DotnetCore ### +# .NET Core build folders +bin/ +obj/ + +# Common node modules locations +/node_modules +/wwwroot/node_modules + +### JetBrains+all ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# SonarLint plugin +.idea/sonarlint/ + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### JetBrains+all Patch ### +# Ignore everything but code style settings and run configurations +# that are supposed to be shared within teams. + +.idea/* + +!.idea/codeStyles +!.idea/runConfigurations + +### VisualStudioCode ### +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +### VisualStudioCode Patch ### +# Ignore all local history of files +.history +.ionide + +### VisualStudio ### ## Ignore Visual Studio temporary files, build results, and ## files generated by popular Visual Studio add-ons. ## @@ -9,7 +129,6 @@ *.user *.userosscache *.sln.docstates -*.env # User-specific files (MonoDevelop/Xamarin Studio) *.userprefs @@ -22,37 +141,17 @@ mono_crash.* [Dd]ebugPublic/ [Rr]elease/ [Rr]eleases/ - -[Dd]ebug/x64/ -[Dd]ebugPublic/x64/ -[Rr]elease/x64/ -[Rr]eleases/x64/ -bin/x64/ -obj/x64/ - -[Dd]ebug/x86/ -[Dd]ebugPublic/x86/ -[Rr]elease/x86/ -[Rr]eleases/x86/ -bin/x86/ -obj/x86/ - +x64/ +x86/ [Ww][Ii][Nn]32/ [Aa][Rr][Mm]/ [Aa][Rr][Mm]64/ -[Aa][Rr][Mm]64[Ee][Cc]/ bld/ +[Bb]in/ [Oo]bj/ -[Oo]ut/ [Ll]og/ [Ll]ogs/ -# Build results on 'Bin' directories -**/[Bb]in/* -# Uncomment if you have tasks that rely on *.refresh files to move binaries -# (https://github.com/github/gitignore/pull/3736) -#!**/[Bb]in/*.refresh - # Visual Studio 2015/2017 cache/options directory .vs/ # Uncomment if you have tasks that create the project's static files in wwwroot @@ -64,16 +163,12 @@ Generated\ Files/ # MSTest test Results [Tt]est[Rr]esult*/ [Bb]uild[Ll]og.* -*.trx # NUnit *.VisualState.xml TestResult.xml nunit-*.xml -# Approval Tests result files -*.received.* - # Build Results of an ATL Project [Dd]ebugPS/ [Rr]eleasePS/ @@ -100,7 +195,6 @@ StyleCopReport.xml *.ilk *.meta *.obj -*.idb *.iobj *.pch *.pdb @@ -108,8 +202,6 @@ StyleCopReport.xml *.pgc *.pgd *.rsp -# but not Directory.Build.rsp, as it configures directory-level build defaults -!Directory.Build.rsp *.sbr *.tlb *.tli @@ -181,7 +273,6 @@ coverage*.info # NCrunch _NCrunch_* -.NCrunch_* .*crunch*.local.xml nCrunchTemp_* @@ -323,13 +414,14 @@ node_modules/ # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) *.vbw +# Visual Studio 6 auto-generated project file (contains which files were open etc.) +*.vbp + # Visual Studio 6 workspace and project file (working project files containing files to include in project) *.dsw *.dsp # Visual Studio 6 technical files -*.ncb -*.aps # Visual Studio LightSwitch build output **/*.HTMLClient/GeneratedArtifacts @@ -340,22 +432,22 @@ node_modules/ _Pvt_Extensions # Paket dependency manager -**/.paket/paket.exe +.paket/paket.exe paket-files/ # FAKE - F# Make -**/.fake/ +.fake/ # CodeRush personal settings -**/.cr/personal +.cr/personal # Python Tools for Visual Studio (PTVS) -**/__pycache__/ +__pycache__/ *.pyc # Cake - Uncomment if you are using it -#tools/** -#!tools/packages.config +# tools/** +# !tools/packages.config # Tabs Studio *.tss @@ -377,19 +469,15 @@ ASALocalRun/ # MSBuild Binary and Structured Log *.binlog -MSBuild_Logs/ - -# AWS SAM Build and Temporary Artifacts folder -.aws-sam # NVidia Nsight GPU debugger configuration file *.nvuser # MFractors (Xamarin productivity tool) working folder -**/.mfractor/ +.mfractor/ # Local History for Visual Studio -**/.localhistory/ +.localhistory/ # Visual Studio History (VSHistory) files .vshistory/ @@ -401,24 +489,15 @@ healthchecksdb MigrationBackup/ # Ionide (cross platform F# VS Code tools) working folder -**/.ionide/ +.ionide/ # Fody - auto-generated XML schema FodyWeavers.xsd # VS Code files for those working on multiple tools -.vscode/* -!.vscode/settings.json -!.vscode/tasks.json -!.vscode/launch.json -!.vscode/extensions.json -!.vscode/*.code-snippets +*.code-workspace # Local History for Visual Studio Code -.history/ - -# Built Visual Studio Code Extensions -*.vsix # Windows Installer files from build outputs *.cab @@ -427,100 +506,20 @@ FodyWeavers.xsd *.msm *.msp -.vscode/* -!.vscode/settings.json -!.vscode/tasks.json -!.vscode/launch.json -!.vscode/extensions.json -!.vscode/*.code-snippets -!*.code-workspace - -# Built Visual Studio Code Extensions -*.vsix - -# Covers JetBrains IDEs: IntelliJ, GoLand, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider -# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 +# JetBrains Rider +*.sln.iml -# User-specific stuff -.idea/**/workspace.xml -.idea/**/tasks.xml -.idea/**/usage.statistics.xml -.idea/**/dictionaries -.idea/**/shelf - -# AWS User-specific -.idea/**/aws.xml +### VisualStudio Patch ### +# Additional files built by Visual Studio -# Generated files -.idea/**/contentModel.xml +# End of https://www.toptal.com/developers/gitignore/api/jetbrains+all,visualstudio,visualstudiocode,dotnetcore -# Sensitive or high-churn files -.idea/**/dataSources/ -.idea/**/dataSources.ids -.idea/**/dataSources.local.xml -.idea/**/sqlDataSources.xml -.idea/**/dynamic.xml -.idea/**/uiDesigner.xml -.idea/**/dbnavigator.xml - -# Gradle -.idea/**/gradle.xml -.idea/**/libraries - -# Gradle and Maven with auto-import -# When using Gradle or Maven with auto-import, you should exclude module files, -# since they will be recreated, and may cause churn. Uncomment if using -# auto-import. -# .idea/artifacts -# .idea/compiler.xml -# .idea/jarRepositories.xml -# .idea/modules.xml -# .idea/*.iml -# .idea/modules -# *.iml -# *.ipr - -# CMake -cmake-build-*/ - -# Mongo Explorer plugin -.idea/**/mongoSettings.xml - -# File-based project format -*.iws - -# IntelliJ -out/ - -# mpeltonen/sbt-idea plugin -.idea_modules/ - -# JIRA plugin -atlassian-ide-plugin.xml - -# Cursive Clojure plugin -.idea/replstate.xml - -# SonarLint plugin -.idea/sonarlint/ -.idea/sonarlint.xml # see https://community.sonarsource.com/t/is-the-file-idea-idea-idea-sonarlint-xml-intended-to-be-under-source-control/121119 - -# Crashlytics plugin (for Android Studio and IntelliJ) -com_crashlytics_export_strings.xml -crashlytics.properties -crashlytics-build.properties -fabric.properties - -# Editor-based HTTP Client -.idea/httpRequests -http-client.private.env.json - -# Android studio 3.1+ serialized cache file -.idea/caches/build_file_checksums.ser +*.env +**/*.lscache -# Apifox Helper cache -.idea/.cache/.Apifox_Helper -.idea/ApifoxUploaderProjectSetting.xml +# Test fixture images — downloaded locally, not committed +tests/fixtures/faces/ -# Github Copilot persisted session migrations, see: https://github.com/microsoft/copilot-intellij-feedback/issues/712#issuecomment-3322062215 -.idea/**/copilot.data.migration.*.xml +.idea +# P-1 UX validation scratch (throwaway, delete after decisions recorded) +.preview/ diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index 838702fc..00000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "backend/src/SlideGenerator.Framework"] - path = backend/src/SlideGenerator.Framework - url = https://github.com/thnhmai06/SlideGenerator.Framework diff --git a/.run/ApplyCopyright.run.xml b/.run/ApplyCopyright.run.xml new file mode 100644 index 00000000..4a719499 --- /dev/null +++ b/.run/ApplyCopyright.run.xml @@ -0,0 +1,24 @@ + + + + \ No newline at end of file diff --git a/.run/Desktop.run.xml b/.run/Desktop.run.xml new file mode 100644 index 00000000..73599820 --- /dev/null +++ b/.run/Desktop.run.xml @@ -0,0 +1,23 @@ + + + + \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json deleted file mode 100644 index 18746450..00000000 --- a/.vscode/launch.json +++ /dev/null @@ -1,74 +0,0 @@ -{ - "version": "0.2.0", - "configurations": [ - // Frontend - { - "name": "[Frontend] Main Process", - "type": "node", - "request": "launch", - "cwd": "${workspaceFolder}/frontend", - "runtimeExecutable": "npm", - "runtimeArgs": [ - "run", - "dev" - ], - "windows": { - "runtimeExecutable": "npm.cmd" - }, - "console": "integratedTerminal", - "presentation": { - "hidden": true - } - }, - { - "name": "[Frontend] Renderer", - "type": "chrome", - "request": "attach", - "port": 9222, - "webRoot": "${workspaceFolder}/frontend/src", - "presentation": { - "hidden": true - } - }, - // Backend - { - "name": "[Backend]", - "type": "coreclr", - "request": "launch", - "program": "dotnet", - "args": [ - "run", - "--project", - "${workspaceFolder}/backend/src/SlideGenerator.Presentation/SlideGenerator.Presentation.csproj" - ], - "cwd": "${workspaceFolder}", - "console": "integratedTerminal", - "stopAtEntry": false, - "env": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - } - ], - "compounds": [ - { - "name": "[All] Backend & Frontend", - "configurations": [ - "[Backend]", - "[Frontend] Main Process" - ], - "presentation": { - "order": 1 - } - }, - { - "name": "[Frontend]", - "configurations": [ - "[Frontend] Main Process", - "[Frontend] Renderer" - ], - "presentation": { - "order": 2 - } - } - ] -} diff --git a/.vscode/tasks.json b/.vscode/tasks.json deleted file mode 100644 index 4efe5c73..00000000 --- a/.vscode/tasks.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "version": "2.0.0", - "tasks": [ - { - "label": "task: test:backend", - "type": "shell", - "command": "task test:backend", - "group": "test", - "problemMatcher": "$msCompile" - }, - { - "label": "task: test:frontend", - "type": "shell", - "command": "task test:frontend", - "group": "test" - }, - { - "label": "task: test", - "type": "shell", - "command": "task test", - "group": "test" - }, - { - "label": "task: format", - "type": "shell", - "command": "task format", - "group": "none" - }, - { - "label": "task: build", - "type": "shell", - "command": "task build", - "group": { - "kind": "build", - "isDefault": true - } - } - ] -} \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..114fe559 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,959 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Basic Rules + +### 1. Think Before Coding + +**Don't assume it. Don't hide confusion. Surface tradeoffs.** + +Before implementing: + +- State your assumptions explicitly. If uncertain, ask. +- If multiple interpretations exist, present them – don't pick silently. +- If a simpler approach exists, say so. Push back when warranted. +- If something is unclear, stop. Name what's confusing. Ask. + +### 2. Simplicity First + +**Minimum code that solves the problem. Nothing speculative.** + +- No features beyond what was asked. +- No abstractions for single-use code. +- No "flexibility" or "configurability" that wasn't requested. +- No error handling for impossible scenarios. +- If you write 200 lines, and it could be 50, rewrite it. + +Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify. + +### 3. Surgical Changes + +**Touch only what you must. Clean up only your own mess.** + +When editing existing code: + +- Don't "improve" adjacent code, comments, or formatting. +- Don't refactor things that aren't broken. +- Match existing style, even if you'd do it differently. +- If you notice unrelated dead code, mention it – don't delete it. + +When your changes create orphans: + +- Remove imports/variables/functions that YOUR changes made unused. +- Don't remove pre-existing dead code unless asked. + +The test: Every changed line should be traced directly to the user's request. + +### 4. Goal-Driven Execution + +**Define success criteria. Loop until verified.** + +Transform tasks into verifiable goals: + +- "Add validation" → "Write tests for invalid inputs, then make them pass" +- "Fix the bug" → "Write a test that reproduces it, then make it pass" +- "Refactor X" → "Ensure tests pass before and after" + +For multistep tasks, state a brief plan: + +``` +1. [Step] → verify: [check] +2. [Step] → verify: [check] +3. [Step] → verify: [check] +``` + +Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification. + +## Build & Test Commands + +```bash +# Build +dotnet build SlideGenerator.slnx + +# Build release +dotnet build SlideGenerator.slnx -c Release + +# Clean +dotnet clean SlideGenerator.slnx + +# Run all tests +dotnet test SlideGenerator.slnx + +# Run tests for one project +dotnet test tests/SlideGenerator.Settings.Tests/SlideGenerator.Settings.Tests.csproj + +# Run a single test by name filter +dotnet test --filter "FullyQualifiedName~Load_SettingsFileNotFound_ReturnsFalse" +``` + +SDK: .NET 10.0 (`global.json` pins to `latestMajor`, allows prerelease). The solution uses the XML-based +`SlideGenerator.slnx` (no `.sln`). A Syncfusion license is required at runtime: copy `.env.example` to `.env` and fill +`SYNCFUSION_LICENSE_KEY` before running the Stdio sidecar. + +**GitHub Packages**: `SlideGenerator.Image` depends on per-platform `SlideGenerator.OpenCvSharp4.runtime.*` packages +hosted at `nuget.pkg.github.com/thnhmai06`. `nuget.config` reads credentials from `%GITHUB_USERNAME%` and +`%GITHUB_TOKEN%` env vars — set these before restoring. + +## Solution Layout + +``` +├── src/ — 11 source modules (slnx-tracked) +│ ├── SlideGenerator.Utilities/ — loose files, no subfolders +│ ├── SlideGenerator.Settings/ — Immutable/, Mutable/, Database/ (NameAndPaths stays put — see below) +│ ├── SlideGenerator.Cloud/ — Resolvers/, root CloudClient.cs +│ ├── SlideGenerator.Document/ — Workbooks/, Slides/, Template/ +│ ├── SlideGenerator.Logging/ — FileLogging/, root formatters/helpers +│ ├── SlideGenerator.Image/ — FaceDetection/, Cropping/, Loading/ +│ ├── SlideGenerator.Jobs/ — Engine/ (generic Job Engine — see Job Execution below) +│ ├── SlideGenerator.Summarization/ — Workbook/, Slide/, root service +│ ├── SlideGenerator.Recipe/ — Mappings/, root RecipeRepository+entry+package rules +│ ├── SlideGenerator.Generator/ — Job/, Persistence/, Progress/, root Service+DTOs +│ └── SlideGenerator.Stdio/ — Handlers/, Implementations/ (already 1 feature = 1 IPC method group) +└── tests/ — 10 test projects (mirrors src, standalone) + ├── SlideGenerator.Utilities.Tests/ + ├── SlideGenerator.Cloud.Tests/ + ├── SlideGenerator.Settings.Tests/ + ├── SlideGenerator.Document.Tests/ + ├── SlideGenerator.Logging.Tests/ + ├── SlideGenerator.Image.Tests/ + ├── SlideGenerator.Jobs.Tests/ + ├── SlideGenerator.Recipe.Tests/ + ├── SlideGenerator.Generator.Tests/ + └── SlideGenerator.Stdio.Tests/ +``` + +All 9 non-host modules now use a **feature-folder** layout — see **Development Patterns → Folder structure** +below for the convention and rationale. `SlideGenerator.Recipe` and `SlideGenerator.Generator` were reorganized last +(after the WorkflowCore-removal rewrite settled their file contents, to avoid reorganizing then immediately rewriting +most of the same files). `SlideGenerator.Stdio`'s `Handlers/`/`Implementations/` split was already feature-shaped (one +handler class per IPC method group) from the start, so it never needed a separate reorg pass. + +**WorkflowCore has been removed entirely** (package, all `Steps/`/`Workflows/`/`Middleware/` folders, the +`Workflows.db` SQLite store) — job execution is now plain `Task`-based C# in a generic **Job Engine** +(`SlideGenerator.Jobs`) driving a slide-generation-specific **Job Workload** (`SlideGenerator.Generator`) — see **Job +Execution (Job Engine + Workload)** below, which replaces the old **Workflow System (WorkflowCore)** +section. `SlideGenerator.Generator`'s `JobRunner` is now a thin adapter over `SlideGenerator.Jobs`'s +`IJobEngine`, not the executor itself — the split (added after the initial WorkflowCore-removal rewrite) +exists so the concurrency/pause/resume/crash-resume machinery is reusable for job types other than slide generation, +without that machinery knowing anything about `JobSnapshot`/phases/Syncfusion. +`SlideGenerator.Recipe`'s `Node`/`Edge` graph model has also been removed — a recipe is now a flat +`Recipe(Mappings)` list (see **Job Execution (Job Engine + Workload) → Input mapping** below). + +`SlideGenerator.Cryptography` module has been removed (its `Sha256` helper moved into +`SlideGenerator.Utilities/Sha256.cs`). `SlideGenerator.Coordinator` has also been removed — its 3 `GateType` +concurrency gates (`DownloadImage`/`EditImage`/`EditPresentation`) and the whole performance-calibration system +(`SettingProbe`/`SettingTuner`/`SettingCalibrator`) were deleted; RAM/concurrency control is now solely +`MaxConcurrentJobs` (see **Concurrency: MaxConcurrentJobs** below). `Coordinator`'s generic `Pool` (the only +still-needed piece, backing `FaceDetectorPool`) moved to `SlideGenerator.Utilities/Pool.cs`. +`src/SlideGenerator.Acquisition/` and `src/SlideGenerator.Collector/` are gone entirely (no longer even present on +disk). `tests/SlideGenerator.Acquisition.Tests/` still exists on disk (only +`bin`/`obj`, no source) but has no matching src project and is **not** in `SlideGenerator.slnx` — an orphan, leave alone +unless asked to clean it up. `scripts/ApplyCopyright/ApplyCopyright.csproj` is also in the slnx (a build-time tool +project, not a module). + +`Summarization` has no dedicated test project. + +## Architecture: Modular Monolith + IPC Sidecar + +SlideGenerator automates PowerPoint generation from Excel data and templates. It is a **Modular Monolith** — each `Job` +runs as a plain in-process `Task` managed by `JobRunner` (no external workflow engine) — exposed to a Tauri frontend +through a JSON-RPC 2.0 IPC sidecar. + +### Module Map + +``` +Foundation Modules +├── SlideGenerator.Utilities - Shared utilities (string normalization, Sha256, generic Pool, helpers) +├── SlideGenerator.Cloud - Multi-cloud URI resolver (Google Drive; OneDrive/SharePoint modules not yet +│ implemented — only Resolver/GoogleDriveModule.cs exists on disk) +├── SlideGenerator.Logging - Serilog: IFileLoggerFactory (FileLogging/), ConsoleLogFormatter +├── SlideGenerator.Document - Syncfusion Excel/PowerPoint abstractions (Workbook/, Slide/) + Mustache +│ template engine (Template/) +├── SlideGenerator.Image - NetVips-based image loading (Loading/); ROI cropping (Cropping/) + face +│ detection via OpenCV YuNet (FaceDetection/) +└── SlideGenerator.Jobs - Generic Job Engine (Engine/): IJobEngine runs any + IJobWorkload — concurrency/pause/resume/stop/crash-resume, with + zero knowledge of what a job does (no dependency on any other module) + +Domain Modules +├── SlideGenerator.Settings - YAML-based configuration; ISettingProvider (Config/) +├── SlideGenerator.Summarization - Workbook/presentation metadata scanner +└── SlideGenerator.Recipe - Recipe CRUD (SQLite) + export/import (*.recipe zip packages); a Recipe is a + flat list of Mappings — no graph/Node/Edge (see Job Execution below) + +Application +└── SlideGenerator.Generator - JobRunner: thin adapter over SlideGenerator.Jobs's IJobEngine, driving + SlideGenerationWorkload (the 4-phase pipeline, in Job/Workload/); spawn + phase (Service.CreateAsync) is plain code too + +Host +└── SlideGenerator.Stdio - JSON-RPC 2.0 IPC sidecar (StreamJsonRpc over stdin/stdout) +``` + +### Dependency Rules + +- Dependencies flow downward only — no circular references. +- Each module has a root `Registration.cs` as DI entry point. +- `SlideGenerator.Stdio` is the executable that wires all modules. + +## DI Registration Methods + +| Module | Extension Method | +|---------------|---------------------------------------------------------------------------------------------------------------| +| Settings | `AddSettingsServices()` | +| Cloud | `AddCloudServices()` | +| Document | `AddDocumentServices()` | +| Image | `AddImageServices()` | +| Logging | `AddLoggingServices()` | +| Jobs | `AddJobEngine()` — generic, called from `AddGeneratorServices()`, not from `Program.Services.cs` | +| Summarization | `AddSummarizationServices()` | +| Recipe | `AddRecipeServices()` | +| Generator | `AddGeneratorServices()` | +| Stdio | `AddIpcServices()` | + +The system logger is bootstrapped up-front by a private `BootstrapSystemLogger(IConfiguration)` method inline in +`src/SlideGenerator.Stdio/Program.cs` (file Serilog sink → `stderr` only), which sets the static `Log.Logger`. It is +**not** passed through DI or into `AddDocumentServices`. + +`Registration.cs` files use C# 14 **extension member syntax**: + +```csharp +extension(IServiceCollection services) +{ + public IServiceCollection AddFooServices() { ... } +} +``` + +## IPC Layer (SlideGenerator.Stdio) + +JSON-RPC 2.0 over stdin/stdout using **StreamJsonRpc** with NDJSON framing (`NewLineDelimitedMessageHandler`) and STJ +serialization (`SystemTextJsonFormatter`). + +### Stream ownership + +| Stream | Owner | Purpose | +|--------|---------------|------------------------------------------| +| stdin | StreamJsonRpc | Incoming JSON-RPC requests | +| stdout | StreamJsonRpc | Outgoing responses **and** notifications | +| stderr | Serilog | System logs only | + +### JsonRpc setup + +`JsonRpc` is created **after** the DI container is built (raw stream access). Not registered in DI. Methods wired via +`AddLocalRpcMethod`: + +```csharp +// DTO param → UseSingleObjectParameterDeserialization = true (via local Attr() helper) +jsonRpc.AddLocalRpcMethod(method, handler, Attr("workflow.start")); + +// No DTO param +jsonRpc.AddLocalRpcMethod(method, handler, new JsonRpcMethodAttribute("settings.get")); +``` + +### Progress notifications + +Progress is scoped to 3 levels — Request/Job/Row (see **Progress model** under **Job Execution (Job Engine + Workload)** +below). Only the `Jobs` table is actually persisted and buffered now — `Requests` is write-once (inserted directly by +`Service.CreateAsync`, never updated) and `Rows`/per-row progress is **never persisted at all**, only forwarded live. +`ProgressCoalescer` (`Implementations/ProgressCoalescer.cs`) does not own its own buffer for Jobs — `IJobsRepository` +(`SlideGenerator.Generator`) already buffers/flushes internally via `BufferedRepository` (coalesced, +last-write-wins, ~1s `PeriodicTimer` tick, see **Job Execution (Job Engine + Workload) → Persistence** below); +`ProgressCoalescer` +just subscribes to `IJobsRepository.Flushed` and relays each batch as `progress/jobs`. `RequestProgress`/`RowProgress` +are forwarded immediately, un-buffered, straight off `GeneratingEventBus`. Log lines remain buffered here in a +`ConcurrentQueue` (append-only, never coalesced — every line matters) with the coalescer's own 1s flush loop, since logs +are never persisted through a repository (they still go to the per-request `.log` file — see **Logging scope +notifications** below). Every notification is sent via `JsonRpc.NotifyAsync(method, payload)` — **not** +`NotifyWithParameterObjectAsync`, which marshals a `List` argument by reflecting over its public properties +(named-parameter convention) and would serialize an empty `{}` instead of the array; `NotifyAsync` binds it positionally +instead (`"params": [[...]]`). Bound at runtime via `JsonRpcBootstrap.AttachProgressCoalescer(services, +jsonRpc)` → `coalescer.Attach(bus, logNotifier, jsonRpc)` — not DI injected, since `JsonRpc` doesn't exist until after +the DI container is built. `DetachAsync()` (called during shutdown in `Program.Startup.cs`) flushes the log queue one +last time before cancelling the timer, so the final <1s window of buffered logs isn't dropped (`Jobs` +flushes itself independently via `JobRunner.ShutdownAsync` → `IJobsRepository.FlushAsync`/`DisposeAsync`). + +`GeneratingEventBus` (in `Implementations/GeneratingEventBus.cs`) is registered as both `GeneratingEventBus` (concrete) +and `IEventBus` (interface, `SlideGenerator.Generator.Abstractions.IEventBus`) in the Stdio +`Registration.cs` so that `ProgressCoalescer.Attach` can receive the concrete type. `LogNotifier`/`ILogNotifier` +mirror the same pattern for log lines (see **Logging scope notifications** below). + +### STJ adapters + +`Implementations/Adapters/` contains custom STJ converters registered in `BuildJsonSerializerOptions()` +(`JsonRpcBootstrap.cs`): + +- `RoiOptionJsonAdapter` — polymorphic `RoiOption` discriminated by `"type"` (`"Center"` | `"RuleOfThirds"`) +- `RectangleFJsonAdapter` — `RectangleF` as `{"x", "y", "width", "height"}` +- `Vector2JsonConverter` (in `Implementations/Adapters/`) + +There is no longer a `NodeJsonConverter` — `SlideGenerator.Recipe`'s `Recipe`/`Mapping`/`WorksheetSource` types are +plain non-polymorphic records now, so the wire shape of `recipe.add`/`recipe.update`/`recipe.query` serializes with +System.Text.Json's default reflection-based behavior. `JobSpecificationJson` (`SlideGenerator.Generator.Adapters`) is a +separate, small `JsonSerializerOptions` used only by `JobsRepository` to serialize the `*Json` columns (`UsedColumns`/ +`TextInstructions`/`ImageInstructions`) — **not** shared with the IPC layer's options, since Generator must not depend +on `SlideGenerator.Stdio` and the equivalent options in `SlideGenerator.Recipe` are `internal`. + +`JsonStringEnumConverter` is registered globally — all enums serialize as strings automatically. + +### Registered methods + +| Method | Handler | +|---------------------------------|---------------------------------------------------| +| `generator.active.create` | `GeneratingActiveHandler.CreateAsync` | +| `generator.active.stop` | `GeneratingActiveHandler.StopAsync` | +| `generator.active.pause` | `GeneratingActiveHandler.PauseAsync` | +| `generator.active.resume` | `GeneratingActiveHandler.ResumeAsync` | +| `generator.active.stopAll` | `GeneratingActiveHandler.StopAllAsync` | +| `generator.active.pauseAll` | `GeneratingActiveHandler.PauseAllAsync` | +| `generator.active.list` | `GeneratingActiveHandler.ListAsync` | +| `generator.completed.list` | `GeneratingCompletedHandler.ListAsync` | +| `generator.completed.delete` | `GeneratingCompletedHandler.DeleteAsync` | +| `generator.completed.deleteAll` | `GeneratingCompletedHandler.DeleteAllAsync` | +| `recipe.list` | `RecipeHandler.ListAsync` | +| `recipe.query` | `RecipeHandler.QueryAsync` | +| `recipe.add` | `RecipeHandler.AddAsync` | +| `recipe.update` | `RecipeHandler.UpdateAsync` | +| `recipe.delete` | `RecipeHandler.DeleteAsync` | +| `recipe.export` | `RecipeHandler.ExportAsync` | +| `recipe.import` | `RecipeHandler.ImportAsync` | +| `summarization.workbook` | `SummarizationHandler.SummarizeWorkbookAsync` | +| `summarization.presentation` | `SummarizationHandler.SummarizePresentationAsync` | +| `settings.get` | `SettingsHandler.GetAsync` | +| `settings.update` | `SettingsHandler.UpdateAsync` | +| `settings.reset` | `SettingsHandler.ResetAsync` | +| `settings.performance.get` | `SettingsHandler.GetPerformanceAsync` | +| `settings.performance.update` | `SettingsHandler.UpdatePerformanceAsync` | +| `settings.performance.reset` | `SettingsHandler.ResetPerformanceAsync` | +| `settings.network.get` | `SettingsHandler.GetNetworkAsync` | +| `settings.network.update` | `SettingsHandler.UpdateNetworkAsync` | +| `settings.network.reset` | `SettingsHandler.ResetNetworkAsync` | + +Notifications emitted by the sidecar: `progress/request`, `progress/jobs`, `progress/rows`, `log/entries` — +`progress/jobs` +is batched at up to 1/s (piggybacking on `IJobsRepository`'s flush tick), `log/entries` is separately batched at up to +1/s; `progress/request`/`progress/rows` are sent immediately, one notification per event (never buffered). Every payload +is a single positional array argument (`params[0]`), a list of `RequestProgress`/`JobSnapshot`/`RowProgress`/ +`LogEntry` respectively — not a named-object param. There is no separate `JobProgress` DTO — `JobSnapshot` (a job's full +current-state row) doubles as the job-scoped progress payload (see **Job Execution (Job Engine + Workload) → Progress +model** below). + +## Concurrency: MaxConcurrentJobs + +There is no per-operation concurrency gate anywhere in the pipeline (downloading, image editing, and presentation saving +run uncontended within a job). The **sole** concurrency/RAM control is at the job level, owned by the generic +`JobEngine` (`SlideGenerator.Jobs/Engine/JobEngine.cs`) via a plain `SemaphoreSlim` field — no external +workflow engine involved, and the engine itself has no idea this limit has anything to do with slide generation +specifically. + +`JobEngine.ApplyConcurrencyLimit()` (private) reads `IJobConcurrencyProvider.MaxConcurrentJobs` and does +`_semaphore = new SemaphoreSlim(value, value)` — but **only when `value` actually changed** since the last apply +(tracked via a private `_currentLimit` field). Swapping unconditionally on every call (the original, pre-split behavior) +would give each newly-started job its own private, uncontended semaphore instead of sharing the pool with jobs already +running — silently defeating the limit for any two jobs that don't happen to start in the same tick. When the value does +change, a **new semaphore instance** is swapped in rather than mutating the old one, so jobs already waiting on the +previous instance are unaffected by the change; only newly-queued waits see the new limit. `RunJobAsync` captures +`_semaphore` into a local **once**, before `WaitAsync`, and reuses that same local for the matching `Release()` — +reading the mutable field again at release time could otherwise release a slot on a semaphore the job never acquired +from (`SemaphoreFullException`) if a swap happened while it was running. `ApplyConcurrencyLimit()` is called once in +`InitializeAsync` (startup) and again at the start of every `StartJobAsync` call. In `SlideGenerator.Generator`, +`IJobConcurrencyProvider` is implemented by +`SettingConcurrencyProvider` (`Job/SettingConcurrencyProvider.cs`), which reads +`settingProvider.Current.Performance.MaxConcurrentJobs` fresh on every property access — so a +`settings.performance.update` takes effect for the *next* job spawned, no restart needed (existing running jobs are +unaffected either way, since they already acquired their semaphore slot). `Setting.PerformanceSetting.MaxConcurrentJobs` +(default 5) is the **only** field left on `PerformanceSetting` — the old `MaxParallelDownloadImage`/ +`MaxParallelEditImage`/ +`MaxParallelEditPresentation`/`MaxParallelReadWorkbook`/`MaxParallelReadPresentation` fields and the whole +hardware/network probing system that calibrated them (`SettingProbe`, `SettingTuner`, `SettingCalibrator`, +`ISettingCalibrator`, the `settings.performance.calibrate` IPC method) were deleted along with the old gates — there is +nothing left to calibrate. + +This cap only throttles job *execution* (`JobEngine.RunJobAsync` awaits the semaphore after publishing the +"starting execution" progress tick but before doing any real work) — it never delays *accepting* a new generation +request, since `Service.CreateAsync`'s spawn phase (recipe read, job-list computation, +`StartJobAsync` calls) is plain C# code that returns immediately per job, not itself gated (see **Job Execution (Job +Engine + Workload)** below). + +`SlideGenerator.Image`'s `FaceDetectorPool` (a separate concern — pools actual `IFaceDetector`/OpenCV instances, not a +throughput gate) is unrelated to `MaxConcurrentJobs`; it is bounded by a static `Environment.ProcessorCount` limit +(CPU-bound native work) via the generic `Pool` living in `SlideGenerator.Utilities/Pool.cs`. + +## Image Processing + +`SlideGenerator.Image` (`Loading/`) uses **NetVips** (`IImage`, implemented by `VipsImage`) as the primary in-memory +image type, loaded via `IImageLoader.Open(path|byte[])`. Convert to/from `byte[]` only at the system boundary — the crop +pipeline is fully in-memory end to end and the result is written straight into `IShape.ImageData`, never to a disk file +(see `SlideGenerationWorkload.cs` → `CropToPngAsync`). + +- `IImageLoader.Open(string path)` / `Open(byte[] data)` → `IImage` +- `ISmartCropper.CropAsync(IImage, Size targetSize, IReadOnlyList roiOptions)` → `IImage?` — tries each + `RoiOption` in order (anchor-based via `IAnchorCropper`, content-aware via `IInterestCropper`), returns the first that + succeeds +- `IImage.ToPng()` → `byte[]` (only place a `byte[]` re-appears, right before `IShape.ImageData = imageData`) +- Face detection (`FaceDetection/`): `IFaceDetector.DetectAsync` (OpenCV YuNet, `YuNet.cs`), pooled via + `FaceDetectorPool` (bounded by `Environment.ProcessorCount`, see **Concurrency** above) +- Always use `using`/`await using` for `IImage` disposal. + +## Job Execution (Job Engine + Workload) + +**One `Job` = one in-process `Task`, tracked in an in-memory registry.** Job execution is split across two layers, added +after the initial WorkflowCore-removal rewrite settled on the flat `Task.Run`-based design: + +- **`SlideGenerator.Jobs`** (`Engine/`) — a fully generic, domain-free **Job Engine**. `IJobEngine` + (`JobEngine`) owns the in-memory running-job registry, the concurrency semaphore, pause/cancel + checkpointing (`PauseGate`), and crash-resume orchestration. It never inspects `TState` — every transition is just + forwarded to a consumer-supplied `IJobObserver`. It has **zero** project references — not even to + `SlideGenerator.Settings` (the concurrency limit comes from an injected `IJobConcurrencyProvider`, not + `ISettingProvider` directly). +- **`SlideGenerator.Generator`** (`Job/`) — the slide-generation-specific **Job Workload**: + `SlideGenerationWorkload : IJobWorkload` (the former 4-phase pipeline logic, in + `Job/Workload/SlideGenerationWorkload.cs`), wrapped in `LoggingWorkload` (a pure decorator that opens the per-job + file-log scope, `Job/Workload/LoggingWorkload.cs`), glued to the engine via + `GeneratorJobObserver : IJobObserver` (persist/publish) and + `GeneratorResumeSource : IJobResumeSource` (crash-resume). `JobKey` is a project-wide + `global using` alias for `(string RequestId, int JobId)` (`Job/JobKey.cs`). `JobRunner` + (`Job/JobRunner.cs`, implements `IJobRunner`) is now just a thin adapter forwarding + `InitializeAsync`/`ShutdownAsync`/`StartJobAsync`/`PauseJobAsync`/`ResumeJobAsync`/`StopJobAsync` onto the matching + `IJobEngine` calls — its public contract is unchanged from before the split, so + `Service.cs`/`Program.Startup.cs`/`ServiceTests.cs` (which mocks `IJobRunner`) never needed to change. + +Each job runs its 4 phases sequentially, in order, inside `SlideGenerationWorkload.RunAsync`: + +``` +CreatingOutput → CreatingSlides → FillingText → FillingImages (→ Done) +``` + +`JobPhase` (`Job/Models/JobPhase.cs`) has these 5 values (`Done` is the terminal value stamped onto the final +`JobSnapshot`, never actually "run"). Phase bodies live in `SlideGenerationWorkload.cs`, each in its own +`#region`: + +- **Phase A — output** (`OpenOutputAsync`/`CreateOutputAsync`/`LoadTemplateSlideAsync`): creates the output + `.pptx` (copies the template file, strips its slides) if it doesn't exist yet, or reopens it as-is on resume. +- **Phase B — slides** (`RunCreatingSlidesAsync`): appends one cloned template slide per data row, `output.Save()` + after each. +- **Phase C — text** (`RunFillingTextAsync`, `BuildRowTextValues`): fills placeholder text into each slide via + `ITextComposer.Compose`, `output.Save()` after each row. +- **Phase D — images** (`InspectSourcesAsync`, `RunFillingImagesAsync`, `ResolveShapeImageAsync`, + `EnsureDownloadedAsync`, `CropToPngAsync`): inspects every image source once up front (URL → `ContentInfo`, via + `ICloudClient.InspectAsync`, deduped per job), then per row: downloads (if not already cached, see **Per-job download + cache** below), crops via `ISmartCropper`, and assigns `IShape.ImageData`, `output.Save()` + after each row. + +There is no `ForEach`/barrier orchestration and no separate "spawn phase workflow" concept — `Service.CreateAsync` +(`Service.cs`) reads the recipe, computes the job list (`Service.BuildJobs`, internal static — a plain +`Recipe.Mappings`-to-`List` flattening, see **Input mapping** below), and loops +`jobRunner.StartJobAsync(requestId, jobId, spec, logPath, ct)` once per job — all plain async C# code. +`JobRunner.StartJobAsync` persists the job's initial `Pending` `JobSnapshot` synchronously (flushed immediately, not on +the next 1s tick — visible to `FindConflictingOutputPathAsync`'s conflict check right away, see **Persistence** below), +then wraps `SlideGenerationWorkload` in a `LoggingWorkload` and calls +`IJobEngine.StartJobAsync`, which registers the job and fires it on `Task.Run` without waiting. Multiple active requests +for the **same recipe** are allowed to run concurrently — there is no recipe-level guard (deleting/updating a recipe +definition doesn't need one either: every `JobSnapshot` already carries its own fully-resolved `JobSpecification`, +snapshotted from the recipe at spawn time — see **Input mapping**). Instead, +`Service.CreateAsync` guards at the **output-path** level via the private `FindConflictingOutputPathAsync` (not exposed +on `IService`): after computing the new request's job list, it checks every already-active (running/pending/paused) job +across all requests for an `OutputPath` collision and throws if one is found. + +**Pause/cancel checkpointing**: `PauseGate` (`SlideGenerator.Jobs/Engine/PauseGate.cs`, engine-owned — one instance per +running job, not DI-registered) wraps a swappable `TaskCompletionSource`. `Pause()`/`Resume()` +toggle the signal; the workload calls `await context.CheckpointAsync(ct)` (forwarded straight to the gate) +**before each row**, plus phase transitions naturally fall between checkpoints too — so pause/cancel granularity is +"between rows," never mid-row (cooperative, not preemptive — a checkpoint can never interrupt a step already in flight). +`IJobEngine.StopAsync` cancels the job's own `CancellationTokenSource` and also calls `Gate.Resume()` +so a paused job unblocks immediately to observe the cancellation rather than sitting blocked on the pause signal +forever; `ShutdownAsync` does the same for every running job before awaiting them all to unwind. + +**State ownership**: `IJobWorkload.RunAsync` is the only place that creates a new `TState`. On normal +completion, its **return value** — not whatever it last reported — is the terminal state passed to +`IJobObserver.OnTerminalAsync`. On cancel/fault (the run threw, so there is no return value), the terminal state is the +last one the workload passed to `IJobContext.ReportAsync`, which the engine caches per running job (never the initial +state, never reconstructed after the fact). + +**Data model** (`Job/Models/`): there is no `JobContext`/`TransientContext` split — `JobSpecification` +(fully resolved: `WorkbookPath`, `WorksheetName`, `UsedColumns`, `RowFilter`, `TemplatePresentationPath`, +`TemplateSlideIndex`, `TextInstructions`, `ImageInstructions`, `OutputPath`) plus 4 scalars (`JobStatus`/`Phase`/ +`CurrentIndex`/`Timestamp`) fully describe a job's state in one record, `JobSnapshot` +(`Job/Models/JobSnapshot.cs`). A `JobSnapshot` needs nothing else to run or resume — no recipe/workbook lookup, no +transient-only fields to reconstruct after a restart. This is also exactly the `TState` used everywhere +`IJobEngine` is instantiated for slide generation (`TKey` = `JobKey` = `(string RequestId, int +JobId)`). + +**Persistence**: `IJobsRepository`/`JobsRepository` (`Persistence/IJobsRepository.cs`, +`Persistence/JobsRepository.cs`) persist `JobSnapshot`s to the shared `Data.db` (see **Data.db** below), buffered via +`BufferedRepository` (`Persistence/BufferedRepository.cs`) — a small generic base class: callers +`Enqueue(key, value)` (coalesced, last-write-wins per key), a background `PeriodicTimer` (~1s) atomically drains the +dirty dictionary (`Interlocked.Exchange`) and calls the abstract `UpsertBatchAsync` once per tick in one transaction, +then raises `Flushed` with the batch. `JobsRepository` is the only subclass today; `IRequestsRepository`/ +`RequestsRepository` deliberately does **not** inherit it (a request row is written once at creation and never updated, +so buffering would add nothing — see **Data.db** below). `SlideGenerator.Jobs` itself knows nothing about +SQLite/buffering — all of this lives behind `GeneratorJobObserver`, which the engine calls into via the generic +`IJobObserver.OnProgressAsync(key, state, durable, ct)` — `durable` distinguishes a coalesced per-row write +from one that must be flushed before the workload's phase transition proceeds (e.g. so a crash right after a phase +boundary resumes from the right phase, not a stale one). + +**Crash-resume**: `JobRunner.InitializeAsync` (called once at startup, before the JSON-RPC connection opens) +calls `IJobEngine.InitializeAsync(resumeSource, ct)`, which schedules every job `GeneratorResumeSource` returns and +returns immediately — it does **not** wait for any of them to finish. `GeneratorResumeSource` queries +`IJobsRepository.GetNonTerminalAsync()` — any row still `Pending`/`Running`/`Paused` when the process starts is by +definition a crash leftover (`Phase`+`CurrentIndex` say exactly where to pick up; `JobSpecification` says exactly what +to do — no recipe/workbook lookup needed at all) — and for each one looks up the owning +`RequestRecord.LogPath` via `IRequestsRepository`, so the resumed run logs to the **same** file the original run used +(fixed as part of building `GeneratorResumeSource`, since it's the class that owns resume reconstruction — before the +split, resume always fell back to a synthetic `{requestId}.log` path regardless of what the request actually used). A +job that was `Paused` before the crash resumes as plain `Running` — there is no persisted concept of "why it was paused" +to restore, and closing/reopening file handles on pause was never implemented (see the "known limitation" remark on +`IJobRunner`) so there's nothing to reopen either; the client can re-pause it if it wants. `PreflightCleanup` is **not** +re-run on resume — `SlideGenerationWorkload` checks +`context.IsResume` (set by the engine depending on whether the run came from `StartJobAsync` or the resume path) instead +of a constructor/method parameter. + +**Request/job identity**: unchanged from before the split — a client-facing `requestId` +(`Guid.NewGuid().ToString()`, minted once in `Service.CreateAsync`) groups N `JobSnapshot`s; `JobId` is a **plain `int`, +0-based ordinal position within the request** (assigned by the `for` loop in `Service.CreateAsync`) — not a GUID, not +self-generated by anything. There is no dedicated "request" row/type beyond `RequestRecord` (see **Data.db**) — +`Service.ListGroupsAsync` (internal) +groups `IJobsRepository.GetAllAsync()`'s flat result by `RequestId` on every call; `Summary` +(`Summary.cs`) itself carries no `RequestId` field — `IService.ListActiveAsync`/`ListCompletedAsync` +return `IReadOnlyDictionary` keyed by `RequestId` instead, so the id lives only as the dictionary key. +`Service.DeriveStatus` (internal static) aggregates a group's `JobSnapshot.JobStatus` values into one request-level +`Status`: any `Running`/`Pending` → `Running`; else any `Paused` → `Paused`; else all `Cancelled` → `Cancelled`; else → +`Complete`. `Summary` is two-level: request-level fields (`Request`, aggregate `Status`, `Phase` +(`RequestPhase?`, computed — see **Progress model** below), `CreatedAt`/`CompletedAt`, request-scoped `Logs`) plus +`Jobs` — an `IReadOnlyDictionary` keyed by job id. `JobSummary` (`Status`, `Phase`, `CurrentIndex`, +`OutputPath`, `CompletedAt`, `Logs`) has **no `Rows` field** — per-row history is not persisted at all (see **Progress +model** below), so historical row-level detail simply doesn't exist past the moment it happens; only +`progress/rows` (live) carries it. +`IService.StopAsync`/`PauseAsync`/`ResumeAsync` (request-scoped — take a `requestId`) fan out best-effort over a +request's job list via `Service.FanOutAsync`, returning `PartialResult(Succeeded, Skipped)` — jobs already in a +terminal/non-eligible state count as skipped, not failed. There is no per-job variant of these on `IService` — its only +surface is request/recipe-scoped: `CreateAsync`, `StopAsync`/`PauseAsync`/`ResumeAsync` (+ `StopAllAsync`/ +`PauseAllAsync` bulk variants), `ListActiveAsync`/`ListCompletedAsync`, `DeleteAsync` (stops the request first if still +active), and `DeleteAllCompletedAsync`. There is no single-request query method — a client looks up one request by +indexing the `ListActiveAsync`/`ListCompletedAsync` result dictionary by `requestId`. + +**Progress model** (`Progress/Progress.cs`) is 2 records now (`RequestProgress`, `RowProgress`) — **there is no separate +`JobProgress` DTO**; `JobSnapshot` itself (a job's full current-state row, see **Data model** above) +doubles as the job-scoped progress payload published via `IEventBus.Publish(JobSnapshot)`, since a job's current state +*is* its progress: + +- `RequestProgress` — `RequestId`, `Phase` (`RequestPhase`: `PreparationStarted` | `ProcessingStarted` | `Completed`, + monotonically increasing), `Timestamp`. Published by `Service.CreateAsync` (`PreparationStarted`, right before the + spawn loop) and inferred by `ProgressCoalescer` (`ProcessingStarted`/`Completed`, see below) — **never persisted** + (see **Data.db** below), purely a live notification. +- `RowProgress` — `RequestId`, `JobId`, `RowIndex` (1-based), `Status` (`RowStatus`: `Waiting`/`Processing`/`Done`/ + `Error`), `Stage` (`RowStage`: `None`/`Downloading`/`CroppingImage`/`SavingOutput`), `Note` (free text — e.g. the URL + being downloaded, or a row's failure message), `Timestamp`. Published exclusively via + `SlideGenerationWorkload.ReportRow` (a private helper, one call per row/image, like a logger call, straight onto the + injected `IEventBus` — unrelated to `IJobContext.ReportAsync`, which only ever carries a + `JobSnapshot`) — also **never persisted**, forwarded live only. The per-row loop bodies don't wrap individual rows in + try/catch to report `RowStatus.Error` and continue — any exception during a row propagates up through + `RunAsync` and fails the whole job (caught by `JobEngine.RunJobAsync`'s outer try/catch, which reports + `JobOutcome.Faulted` to `IJobObserver.OnTerminalAsync`; `GeneratorJobObserver` maps that to `JobStatus.Error`). + +`Service.CreateAsync` publishes the job's initial `Pending` `JobSnapshot` indirectly (via `JobRunner.StartJobAsync` +→ `IJobsRepository.Enqueue`/immediate `FlushAsync`, not through `IEventBus`) — the transitions actually published via +`IEventBus` are all routed through `GeneratorJobObserver`: `OnProgressAsync` (`Running` — including the tick published +right before the engine acquires a concurrency slot, so a queued job still shows as running), and +`OnPausedAsync`/`OnResumedAsync`/`OnTerminalAsync` (`Paused`/`Running`/`Complete`|`Cancelled`|`Error`). + +**`RequestPhase` aggregation** lives entirely in `ProgressCoalescer` (Stdio), not `Service` — a per-request +`RequestAggregateState` (`ExpectedJobCount`/`KnownJobs`/`StartedJobs`/`TerminalJobs`) tracks every `JobSnapshot` it sees +via `TrackRequestAggregate` (which also does double duty: it's the handler that `Enqueue`s the job into +`IJobsRepository` for persistence). `ExpectedJobCount` comes from `IEventBus.AnnounceExpectedJobCount(requestId, +jobs.Count)`, called by `Service.CreateAsync` right before its spawn loop — using it (rather than however many jobs have +been observed so far) as the denominator avoids a race where job 0 is already `Running` while job 1 hasn't even been +spawned yet, since the spawn loop `await`s each `StartJobAsync` sequentially. `ProcessingStarted` fires once every +announced job has left `Pending`; `Completed` fires once every announced job has reached a terminal `Status`. This +aggregate state is in-memory only, purely for live notification timing — `RequestPhase` itself is **never** +persisted; `Summary.Phase` is instead recomputed on every `ListActiveAsync`/`ListCompletedAsync` call by +`Service`'s own `DeriveRequestPhase` (a much simpler, stateless function operating on the current +`JobSnapshot.JobStatus` +values already fetched from `Data.db` — no in-memory dependency on the coalescer's transition history). + +### Data.db — the shared SQLite database + +There is a **single** SQLite database (`NameAndPaths.DataFolder.DataFile`, `%LOCALAPPDATA%\SlideGenerator\Data\Data.db`) +shared by every module that needs SQLite — `Recipes` (`SlideGenerator.Recipe`'s `RecipeRepository`), `Requests` +(`RequestsRepository`), and `Jobs` (`JobsRepository`), all in the same file. Each repository independently registers its +own `SqliteConnectionStringBuilder(NameAndPaths.DataFolder.DataFile.ConnectionString)` singleton in its own module's +`Registration.cs` (both `SlideGenerator.Recipe/Registration.cs` and +`SlideGenerator.Generator/Registration.cs` do this — functionally harmless, since both point at the identical connection +string). Schema creation is **centralized**: `SlideGenerator.Settings.Database.DatabaseMigrator.Migrate` +(`src/SlideGenerator.Settings/Database/DatabaseMigrator.cs`) runs embedded DbUp scripts from +`Database/Scripts/*.sql` (currently `001_2.0.0.sql` — one consolidated script creating all 3 tables, `PRAGMA +journal_mode=WAL;` prepended — plus `002_add-total-rows-to-jobs.sql`, an `ALTER TABLE Jobs ADD COLUMN TotalRows` +added later; new scripts are picked up automatically via the `.csproj`'s wildcard `EmbeddedResource` glob, no +manual registration needed) against the connection string, tracked via DbUp's own `SchemaVersions` table. Called +once in `SlideGenerator.Desktop/Program.cs`'s `Main`, right after `BootstrapSystemLogger` and before +`Host.CreateApplicationBuilder` (with `Directory.CreateDirectory(NameAndPaths.DataFolder.FolderPath)` first, since +`NameAndPaths.InitializeDirectories()` — which also creates it — doesn't run until later, inside `StartupAsync`). +None of the 3 repositories create their own tables anymore (`DbEnsureCreated` has been removed from all of them); +DbUp's logging is forwarded to Serilog via a small `IUpgradeLog` adapter (`DatabaseMigrator.SerilogUpgradeLog`) +since `LogToAutodetectedLog()` doesn't exist in `dbup-core`. Short-lived-connection-per-operation (open/close per +call) is unchanged for all 3 repositories' CRUD paths. + +- **`Jobs`** — every `JobSpecification` field gets its own explicit column, with one deliberate, *named* exception: + `UsedColumnsJson`/`TextInstructionsJson`/`ImageInstructionsJson` are stored as JSON text, since they're + variable-length lists of nested (sometimes polymorphic) objects that would otherwise need several normalized child + tables serving a query pattern nobody actually uses (a job's spec is read once, whole, when it runs) — mirrors how + `Recipes` already stores an entire `Recipe` graph under one JSON column. `RowFilter` (a small closed set of 3 shapes: + `AllRowFilter`/`IndexRangeFilter`/`PartitionBlockFilter`) instead gets `RowFilterType` + 4 nullable scalar columns, + since it's small and closed enough not to need JSON. `TotalRows` (nullable `int`, added by + `002_add-total-rows-to-jobs.sql`) is not part of `JobSpecification` — it's a workload-computed progress field + (row count known once the workload starts) mirrored onto `JobSnapshot`/`JobSummary` for the Desktop UI's + determinate progress bar. Composite primary key `(RequestId, JobId)`. +- **`Requests`** — one explicit column per `Request` DTO field (`RecipeId`/`Name`/`OutputType`/`SaveFolder`/ + `AllowLocalPaths`) plus `LogPath` (the one `.log` file shared by every job of the request) and `CreatedAt`. + `RecipeId` here is purely informational (`Summary.Request.RecipeId` for display/history) — **not** used at resume, + since `JobSpecification` is already fully resolved. `RequestId TEXT PRIMARY KEY`. +- There is **no `Rows` table** — per-row progress is never persisted (see **Progress model** above). + +`Service.DeleteAsync`/`DeleteAllCompletedAsync` call `jobsRepository.DeleteByRequestIdAsync` and +`requestsRepository.DeleteAsync` so a deleted request doesn't leave orphaned rows in either table. + +### Logging scope notifications + +Log lines are **not** a separate persisted store — they still go to a per-request `.log` file (`RequestRecord.LogPath`, +one file shared by every job of a request), with a parseable scope path on every line. `SlideGenerator.Logging`'s +`IFileLoggerFactory.CreateFile(filePath, scopePropertyNames, onLogEvent)` takes `scopePropertyNames` (an ordered list of +ambient `LogContext.PushProperty` names to join into each event's scope path — Logging itself has **no** notion of what +a scope means, so it doesn't hardcode `RequestId`/`JobId`/`RowIndex` anywhere) and `onLogEvent` (an +`Action` invoked once per log line, wired alongside the file sink via `ScopeNotifyingSink`). +`FileLogFormatter` writes the same scope path into the on-disk line so it can be parsed back out later. +`LogNotification.Level` is `Serilog.Events.LogEventLevel` (not a string) — `LoggingWorkload.RunAsync` converts it to the +file's 3-letter abbreviation (`"INF"`/`"WRN"`/…) at the point it builds the `LogEntry` handed to +`ILogNotifier.Publish` (this conversion used to live in the now-deleted `Middleware.cs`, then in +`JobRunner.RunJobAsync` before the Engine/Workload split; there is no step middleware anymore — +`LoggingWorkload.RunAsync`, the pure decorator wrapping `SlideGenerationWorkload`, does the lazy `ILoggerFactory` +init inline once per job, via a local `using` scoped to that one call). + +`LoggingWorkload.RunAsync` pushes `RequestId`/`JobId` onto `LogContext` for the duration of the whole job (and each +phase's per-row loop inside `SlideGenerationWorkload` additionally pushes `RowIndex` for the duration of that row), so +every log line written anywhere during that scope automatically carries the right path. The per-job `ILogger` itself is +threaded from `LoggingWorkload` down to `SlideGenerationWorkload` via a small +`IScopedLoggerContext` (`Logger` property) that `LoggingWorkload` wraps `IJobContext` in — +`SlideGenerator.Jobs`'s generic `IJobContext` contract itself has no notion of logging at all. + +`ILogNotifier`/`LogNotifier` (`SlideGenerator.Stdio/Implementations/LogNotifier.cs`) mirror `IEventBus`/ +`GeneratingEventBus`'s `dep-interface-ownership` pattern exactly, for the one log-line event. `ProgressCoalescer` +subscribes to `LogNotifier.OnLogEntry` and buffers log lines in an **append-only** `ConcurrentQueue` (never +coalesced/dropped — every line matters) and drains the whole queue every ~1s tick as a `log/entries` notification. + +`Service.ToSummaryAsync`/`ToJobSummary` populate `Summary.Logs`/`JobSummary.Logs` by reading the `.log` file straight +off disk on every call, via `ILogFileReader`/`LogFileReader` (`Progress/LogFileReader.cs` — a regex parser matching +`FileLogFormatter`'s line shape) and filtering by scope-path prefix. Deliberately **not** cached in RAM. + +### Input mapping + +`SlideGenerator.Recipe`'s `Recipe` (`Mappings/Recipe.cs`) is a **flat list of `Mapping`s** — there is no graph/`Node`/ +`Edge`/id-lookup anymore: + +```csharp +public sealed record Recipe(IReadOnlyList Mappings); + +public sealed record Mapping( + IReadOnlyList Sources, + PresentationIdentifier TemplatePresentation, + SlideIdentifier TemplateSlide, + IReadOnlyList TextInstructions, + IReadOnlyList ImageInstructions); + +public sealed record WorksheetSource( + WorkbookIdentifier Workbook, + WorksheetIdentifier Worksheet, + IReadOnlySet? UsedColumns = null, + RowFilter? RowFilter = null); +``` + +One `Mapping` = one template slide + its text/image instructions, fed by one or more `WorksheetSource`s (worksheets that +share the same template and instructions — the old graph's only real expressiveness need, now just a nested list, no +ids). `Service.BuildJobs` flattens `Mappings.SelectMany(m => m.Sources.Select(...))` — one +`JobSpecification` per (mapping × source) pair, with every value already resolved (`s.Workbook.BookPath`, +`m.TemplatePresentation.PresentationPath`, etc.) — there is no id left to look up against anything at job-run or resume +time. `TextInstruction`/`ImageInstruction`/`ImageEdits`/`RowFilter` (in `Mappings/`) are unchanged from before — they +were always the legitimate "render/execution config" part, never the part that was over-engineered. +`RecipeRepository`'s import path (`RecipeRepository.Package.cs`) normalizes a deserialized `Recipe` with a possibly +`null` `Mappings` (e.g. an archive whose `recipe.json` is `"{}"`, or crafted maliciously) to `[]` rather than letting a +`NullReferenceException` escape — see `imported = imported with { Mappings = imported.Mappings ?? [] };`. + +`SummarizationService`/`ISummarizationService` (`SlideGenerator.Summarization`, synchronous) provides workbook and +presentation metadata (`WorkbookSummary`, `PresentationSummary`) used to validate instructions before running +generation — unrelated to `JobRunner`'s own recipe-flattening, used only by the `summarization.*` IPC methods for the +frontend's recipe editor. + +## Testing + +### Packages (all test projects) + +```xml + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + +``` + +- **xUnit v3** — use `xunit.v3` package, NOT `xunit` v2. +- `PackageReference Remove="StyleCop.Analyzers"` at top of every test `.csproj` (inherited from `Directory.Build.props` + but not wanted in tests). + +### Test naming + +`[Method]_[Scenario]_[ExpectedResult]` — e.g. `Load_SettingsFileNotFound_ReturnsFalse`. + +### XML documentation + +All test classes and test methods require full XML `` documentation in English. + +### InternalsVisibleTo + +When a test needs access to `internal` types, add to the **source** project's `.csproj`: + +```xml + + + +``` + +### NuGet transitivity pitfall + +`Directory.Build.props` sets `PrivateAssets="all"` on **all** `ProjectReference` items globally. NuGet packages from +referenced projects do **not** flow transitively into test projects. Always add an explicit `PackageReference` for any +NuGet package the test project uses directly — even if the source project already references it. + +Example: `SlideGenerator.Generator.Tests` must explicitly reference `Microsoft.Data.Sqlite` (used directly by +`JobsRepositoryTests.cs` to inspect the temp-file DB) even though `SlideGenerator.Generator` already references it. + +### What NOT to unit test + +`SlideGenerationWorkload`'s phase bodies (`RunAsync` and everything it calls, in +`Job/Workload/SlideGenerationWorkload.cs`) require a Syncfusion license + real `.xlsx`/`.pptx` files, plus real +NetVips/OpenCV image work — they belong to integration tests, not unit tests: opening a workbook/template/output +presentation, face detection and image crop from a real file, appending real slides. Do not create unit stubs that +bypass this core behavior. What **is** safe to unit test directly: + +- `PreflightCleanup` — plain `File`/`Directory` I/O, no Syncfusion + (`tests/SlideGenerator.Generator.Tests/Unit/PreflightCleanupTests.cs`) +- Pure helper functions lifted out of `SlideGenerationWorkload.cs` (e.g. `BuildRowTextValues`) — no I/O + (`tests/SlideGenerator.Generator.Tests/Unit/JobRunnerHelpersTests.cs`) +- `BufferedRepository` — the buffer/flush mechanics are I/O-free and deterministic given a fake + `UpsertBatchAsync` (`tests/SlideGenerator.Generator.Tests/Unit/BufferedRepositoryTests.cs`) +- `JobsRepository`'s row mapping/schema against a real (temp-file) SQLite DB — Dapper + SQLite round-trips don't need + Syncfusion (`tests/SlideGenerator.Generator.Tests/Unit/JobsRepositoryTests.cs`) +- `Service`'s aggregation logic (`DeriveStatus`, `BuildJobs`, `FanOutAsync`) against mocked + `IJobRunner`/`IJobsRepository`/`IRequestsRepository` (`tests/SlideGenerator.Generator.Tests/Unit/ServiceTests.cs`) +- `JobEngine` itself — fully domain-free and deterministically testable with a fake + `IJobWorkload`/`IJobObserver` (no Syncfusion, no real I/O at all): pause/checkpoint cooperation, + stop unblocking a paused job, concurrency-cap sharing and swap-not-resize, non-blocking + `InitializeAsync`, and terminal-state ownership rules (`tests/SlideGenerator.Jobs.Tests/JobEngineTests.cs`) + +## Development Patterns + +### Folder structure + +Three coexisting conventions, by module: + +**Feature-folder** (all 10 non-host modules) — folders named after a business feature/concept, each folder free to mix +interfaces, implementations, models, and even multiple small related classes in one file (the old "1 file = 1 class" +rule doesn't apply within a feature folder — e.g. `SlideGenerator.Document/Workbooks/Workbook.cs` holds both the +`IReadOnlyWorkbook`/`IWorkbook` interfaces and the `SfWorkbook` implementation). Folder names are plural when the +folder's own name would otherwise collide with a type living inside it (e.g. `Recipe/Mappings/` — a +`Mapping.cs` inside a `Mapping/` folder would be a namespace/type name collision, CS0118). Root-level loose files are +fine for cross-feature helpers that don't belong to any one folder (e.g. `SlideGenerator.Logging/Utilities.cs`, used by +both `ConsoleLogFormatter` and everything under `FileLogging/`) or for infra so widely shared that moving it into a +feature folder would be pure namespace churn for no benefit (e.g. +`SlideGenerator.Settings/Immutable/NameAndPaths.cs` stayed at its own top-level location rather than moving into a +feature folder — see **Solution Layout** above). Examples of the convention in practice: + +``` +SlideGenerator.Document/ +├── Workbooks/ — IWorkbookProvider + WorkbookOpener, IWorkbook/IWorksheet + Sf* impls, identifiers +├── Slides/ — IPresentationProvider + PresentationOpener, ISlide/IShape/... + Sf* impls, identifiers +├── Template/ — ITextComposer + TextComposer, ITemplateEngine + TemplateEngine (Mustache-based) +└── Registration.cs + +SlideGenerator.Image/ +├── FaceDetection/ — IFaceDetector, YuNet (OpenCV adapter), YuNetPool, Face +├── Cropping/ — ISmartCropper/IAnchorCropper/IInterestCropper + impls, RoiOption/RoiMode/AnchorType/InterestType +├── Loading/ — IImageLoader + ImageLoader, IImage/IImageInfo, VipsImage +├── AdapterConversions.cs — root: shared conversion helpers used by more than one feature folder above +└── Registration.cs + +SlideGenerator.Recipe/ +├── Mappings/ — Recipe/Mapping/WorksheetSource records, TextInstruction, ImageInstruction+ImageEdits, RowFilter +├── RecipeRepository.cs + .Package.cs — root: CRUD + export/import, RecipeEntry, RecipePackageRules +└── Registration.cs + +SlideGenerator.Jobs/ — foundation module, zero project references +├── Engine/ — IJobEngine + JobEngine, IJobWorkload, IJobContext, IJobObserver, +│ IJobResumeSource + PendingJob, IJobConcurrencyProvider, JobOutcome, JobTerminalResult, +│ PauseGate +└── Registration.cs + +SlideGenerator.Generator/ +├── Job/ +│ ├── Models/ — JobSnapshot, JobSpecification, JobPhase, JobStatus +│ ├── Workload/ — SlideGenerationWorkload (the 4-phase pipeline, IJobWorkload), +│ │ LoggingWorkload (pure decorator: per-job file-log scope) + IScopedLoggerContext +│ ├── JobRunner.cs — IJobRunner + JobRunner, now a thin adapter over IJobEngine +│ ├── GeneratorJobObserver.cs, GeneratorResumeSource.cs — engine↔persistence/progress glue +│ ├── JobKey.cs — global using alias for (string RequestId, int JobId) +│ ├── JobTempFolder.cs, SettingConcurrencyProvider.cs — small shared helpers +│ └── PreflightCleanup.cs +├── Persistence/ — BufferedRepository, IJobsRepository + JobsRepository, IRequestsRepository + +│ RequestsRepository, JobSpecificationJson (STJ options for the `*Json` columns) +├── Progress/ — IEventBus, ILogNotifier, ILogFileReader + LogFileReader, RequestProgress/RowProgress, +│ RowStage, RowStatus +├── IService.cs + Service.cs — root: the IPC-facing facade, doesn't belong to one feature sub-folder +└── Request.cs, Summary.cs, PartialResult.cs, Registration.cs, Utilities.cs — root: shared DTOs/helpers +``` + +Namespace mirrors the physical folder path 1:1 (e.g. `Image/FaceDetection/YuNet.cs` → +`namespace SlideGenerator.Image.FaceDetection;`, `Jobs/Engine/JobEngine.cs` → `namespace SlideGenerator.Jobs.Engine;`, +`Generator/Job/Workload/SlideGenerationWorkload.cs` → `namespace SlideGenerator.Generator.Job.Workload;`, +`Generator/Job/Models/JobSnapshot.cs` → `namespace SlideGenerator.Generator.Job.Models;`). + +**MVVM feature-folder** (`SlideGenerator.Desktop` only, the Avalonia host) — a different shape from the 10 domain +modules above, because it's UI code (Views/ViewModels/Models) rather than service code (interfaces/implementations). +Target convention (folders are created on demand, when a feature/piece is actually built — no placeholder/empty +folders committed ahead of time, per **Simplicity First** in Basic Rules): + +``` +SlideGenerator.Desktop/ +├── App.axaml / App.axaml.cs — Avalonia application object: builds the Host/DI container, shows Shell +├── Program.cs — entry point: single-instance guard, system logging, DB migration, Velopack +├── appsettings.json +├── Bootstrap/ — process-startup infra, used only from Program.cs (SingleInstanceLock, +│ Metadata, UpdateChecker) — distinct from Services/ (UI-facing, ViewModel- +│ consumed) and Infrastructure/ (data access) +├── Shell/ — MainWindow.axaml(.cs) + MainWindowViewModel.cs (app chrome/navigation host, +│ not a feature) +├── Features/ — one folder per business feature, added when that feature is actually built: +│ └── / +│ ├── Views/ — *.axaml(.cs) +│ ├── ViewModels/ — *ViewModel.cs (CommunityToolkit.Mvvm: [ObservableProperty]/[RelayCommand]) +│ ├── Models/ — feature-local view models/DTOs (not domain-module types — those are +│ │ referenced directly from SlideGenerator.Generator/Recipe/Settings/etc.) +│ └── Resources/ — feature-scoped .resx, if the feature has strings not shared elsewhere +├── Components/ — reusable cross-feature Avalonia controls (e.g. JobCard, LoadingIndicator), +│ added when a second feature needs to share one +├── Services/ — UI-facing, ViewModel-consumed cross-cutting services: +│ ├── Progress/ — GeneratingEventBus, LogNotifier (implement SlideGenerator.Generator's +│ │ IEventBus/ILogNotifier — ViewModels subscribe directly, no IPC layer) +│ ├── Localization/ — Resources.resx (default/en) + Resources.{culture}.resx, ResourceManager-based +│ └── (Dialogs/, Navigation/, ... — added when a ViewModel actually needs one) +├── Infrastructure/ — non-UI cross-cutting infra beyond the domain modules (added on demand; +│ most persistence/HTTP already lives in the domain modules themselves) +├── Resources/ — global XAML resource dictionaries (Styles/, Themes/, Icons/, Fonts/) — +│ added once real theming beyond the default Semi.Avalonia theme is needed +└── Assets/ — appicon.ico, Images/, ... +``` + +Namespace mirrors the physical folder path, same as the 10 domain modules (e.g. `Shell/MainWindow.axaml.cs` → +`namespace SlideGenerator.Desktop.Shell;`, `Services/Progress/LogNotifier.cs` → +`namespace SlideGenerator.Desktop.Services.Progress;`). + +### Partial classes for large single-concept services + +`RecipePackageService` (`SlideGenerator.Recipe`) is a `partial class` split across `RecipePackageService.cs` +(shared state/helpers), `.Export.cs`, and `.Import.cs` — kept because one export/import concept genuinely needs more +code than fits comfortably in one file, not because of any special convention. `JobRunner` +(`SlideGenerator.Generator`) used to follow this same pattern (`JobRunner.cs` + `JobRunner.Phases.cs`, one +`#region` per phase) before the Engine/Workload split (see **Job Execution (Job Engine + Workload)** above) +moved the 4-phase pipeline body out into `SlideGenerationWorkload.cs` — a single, plain (non-partial) file. +`JobRunner` itself shrank down to a thin adapter over `IJobEngine` and no longer needs splitting. + +### Coding Style + +- `record` for DTOs/value objects; `sealed class` for services. +- File-scoped namespaces. +- `ConfigureAwait(false)` in all library/module async code. +- Primary constructors (C# 12) for services: `public sealed class Foo(IBar bar) : IFoo`. +- Extension members (C# 14) for `Registration.cs` and `Utilities.cs`. +- Class names: max three words. +- Use `#region`/`#endregion` to delimit logical sections within a file — never plain `//` comments for section + separation. + +## Security Patterns (CodeQL) + +CodeQL config lives at `.github/codeql/codeql-config.yml` and excludes `tests/**` — test fixtures use deliberate +hardcoded paths and are not production code. + +### Path injection (`cs/path-injection`) + +`Path.GetFullPath()` is CodeQL's recognized sanitizer. Apply it at every entry point that receives a user-supplied path: + +```csharp +// method entry — breaks taint chain +filePath = Path.GetFullPath(filePath); +``` + +`NameAndPaths.UserPath` resolves to `%LOCALAPPDATA%\SlideGenerator` normally, or to `BasePath` (executable directory) +when the `--portable` flag is passed. Both branches are wrapped with `Path.GetFullPath` so all derived paths inherit the +sanitization. **Do not remove those wrappers.** + +`NameAndPaths.IsPortable` (private) is checked at each property access — no caching — so the flag is respected even if +checked early at startup. + +Sub-path layout under `UserPath`: + +``` +UserPath/ +├── Instance.pid — AppLocker +├── Logs/System/ — LogsFolder.SystemPath +├── Logs/Workflows/ — LogsFolder.WorkflowPath (per-request .log files; folder name predates the +│ WorkflowCore removal, kept as-is — not worth the rename churn) +└── Data/ + ├── Data.db — DataFolder.DataFile (single shared SQLite DB: Recipes/Requests/Jobs tables — + │ see Job Execution (Job Engine + Workload) → Data.db above) + └── UserSettings.json — DataFolder.SettingsFile (JSON, "Application" section) + +TempFolder.RootPath (%TEMP%\SlideGenerator) — per-job download cache, outside UserPath. Structured as +{RootPath}/{requestId}/{jobId}/{hash(url)}{ext} (see JobTempFolder.GetPath, Generator/Job/JobTempFolder.cs) — +deleted wholesale by GeneratorJobObserver.OnTerminalAsync once that specific job reaches a terminal (non-Paused) +outcome; no shared/cross-job cache anymore. +``` + +### Resource injection (`cs/resource-injection`) + +SQLite connection strings must use `SqliteConnectionStringBuilder`, not string interpolation — the interpolation is what +CodeQL tracks: + +```csharp +// ✅ +new SqliteConnectionStringBuilder { DataSource = filePath }.ConnectionString + +// ❌ — trips cs/resource-injection even with sanitized filePath +$"Data Source={filePath}" +``` + +### Log forging (`cs/log-forging`) + +Strip line endings from path values before logging. `SettingManager` has a `private static string L(string? s)` helper +for this; replicate the pattern in any new service that logs file paths from external input. + +## Invariants Checklist + +- [ ] Each module has root `Registration.cs` with DI setup +- [ ] Module dependencies flow downward only +- [ ] `SlideGenerationWorkload` phase bodies checkpoint (`context.CheckpointAsync` + + `ct.ThrowIfCancellationRequested()`) + before every row, never mid-row +- [ ] `SlideGenerator.Jobs` (`Engine/`) stays domain-free — no reference to `JobSnapshot`/`JobStatus`/Syncfusion/ + `Settings`/SQLite, and no project references to any other module +- [ ] Async code uses `ConfigureAwait(false)` +- [ ] `record` for data, `sealed` for logic by default +- [ ] Image handling uses `IImage`/NetVips; byte arrays only at boundaries +- [ ] All public APIs have XML documentation comments +- [ ] IPC methods with a DTO param use `UseSingleObjectParameterDeserialization = true` (via the `Attr()` helper in + `JsonRpcBootstrap.cs`) +- [ ] Serilog never writes to stdout — stderr only +- [ ] User-supplied file paths go through `Path.GetFullPath()` at method entry +- [ ] SQLite connection strings use `SqliteConnectionStringBuilder`, not string interpolation +- [ ] New SQLite tables land in the single shared `Data.db`, not a new per-purpose file +- [ ] Deserializing external/untrusted data (recipe imports, etc.) normalizes possibly-`null` collection fields to empty + rather than letting a `NullReferenceException` escape (see `Recipe.Package.cs`'s `Mappings ?? []`) diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 3be288c4..db0d81d1 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -1,49 +1,138 @@ -# Contributor Covenant Code of Conduct +# Contributor Covenant 3.0 Code of Conduct ## Our Pledge -We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex, gender characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation. - -We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. - -## Our Standards - -Examples of behavior that contributes to a positive environment for our community include: - -* Demonstrating empathy and kindness toward others -* Being respectful of differing opinions, viewpoints, and experiences -* Giving and gracefully accepting constructive feedback -* Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience -* Focusing on what is best not just for us as individuals, but for the overall community - -Examples of unacceptable behavior include: - -* The use of sexualized language or imagery, and sexual attention or advances of any kind -* Trolling, insulting or derogatory comments, and personal or political attacks -* Public or private harassment -* Publishing others' private information, such as a physical or email address, without their explicit permission -* Other conduct which could reasonably be considered inappropriate in a professional setting - -## Enforcement Responsibilities - -Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. - -Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. +We pledge to make our community welcoming, safe, and equitable for all. + +We are committed to fostering an environment that respects and promotes the dignity, rights, and contributions of all +individuals, regardless of characteristics including race, ethnicity, caste, color, age, physical characteristics, +neurodiversity, disability, sex or gender, gender identity or expression, sexual orientation, language, philosophy or +religion, national or social origin, socio-economic position, level of education, or other status. The same privileges +of participation are extended to everyone who participates in good faith and in accordance with this Covenant. + +## Encouraged Behaviors + +While acknowledging differences in social norms, we all strive to meet our community's expectations for positive +behavior. We also understand that our words and actions may be interpreted differently than we intend based on culture, +background, or native language. + +With these considerations in mind, we agree to behave mindfully toward each other and act in ways that center our shared +values, including: + +1. Respecting the **purpose of our community**, our activities, and our ways of gathering. +2. Engaging **kindly and honestly** with others. +3. Respecting **different viewpoints** and experiences. +4. **Taking responsibility** for our actions and contributions. +5. Gracefully giving and accepting **constructive feedback**. +6. Committing to **repairing harm** when it occurs. +7. Behaving in other ways that promote and sustain the **well-being of our community**. + +## Restricted Behaviors + +We agree to restrict the following behaviors in our community. Instances, threats, and promotion of these behaviors are +violations of this Code of Conduct. + +1. **Harassment.** Violating explicitly expressed boundaries or engaging in unnecessary personal attention after any + clear request to stop. +2. **Character attacks.** Making insulting, demeaning, or pejorative comments directed at a community member or group of + people. +3. **Stereotyping or discrimination.** Characterizing anyone’s personality or behavior on the basis of immutable + identities or traits. +4. **Sexualization.** Behaving in a way that would generally be considered inappropriately intimate in the context or + purpose of the community. +5. **Violating confidentiality**. Sharing or acting on someone's personal or private information without their + permission. +6. **Endangerment.** Causing, encouraging, or threatening violence or other harm toward any person or group. +7. Behaving in other ways that **threaten the well-being** of our community. + +### Other Restrictions + +1. **Misleading identity.** Impersonating someone else for any reason, or pretending to be someone else to evade + enforcement actions. +2. **Failing to credit sources.** Not properly crediting the sources of content you contribute. +3. **Promotional materials**. Sharing marketing or other commercial content in a way that is outside the norms of the + community. +4. **Irresponsible communication.** Failing to responsibly present content which includes, links or describes any other + restricted behaviors. + +## Reporting an Issue + +Tensions can occur between community members even when they are trying their best to collaborate. Not every conflict +represents a code of conduct violation, and this Code of Conduct reinforces encouraged behaviors and norms that can help +avoid conflicts and minimize harm. + +When an incident does occur, it is important to report it promptly. To report a possible violation, * +*[NOTE: describe your means of reporting here.]** + +Community Moderators take reports of violations seriously and will make every effort to respond in a timely manner. They +will investigate all reports of code of conduct violations, reviewing messages, logs, and recordings, or interviewing +witnesses and other participants. Community Moderators will keep investigation and enforcement actions as transparent as +possible while prioritizing safety and confidentiality. In order to honor these values, enforcement actions are carried +out in private with the involved parties, but communicating to the whole community may be part of a mutually agreed upon +resolution. + +## Addressing and Repairing Harm + +* + +*[NOTE: The remedies and repairs outlined below are suggestions based on best practices in code of conduct enforcement. If your community has its own established enforcement process, be sure to edit this section to describe your own policies.] +** + +If an investigation by the Community Moderators finds that this Code of Conduct has been violated, the following +enforcement ladder may be used to determine how best to repair harm, based on the incident's impact on the individuals +involved and the community as a whole. Depending on the severity of a violation, lower rungs on the ladder may be +skipped. + +1) Warning + 1) Event: A violation involving a single incident or series of incidents. + 2) Consequence: A private, written warning from the Community Moderators. + 3) Repair: Examples of repair include a private written apology, acknowledgement of responsibility, and seeking + clarification on expectations. +2) Temporarily Limited Activities + 1) Event: A repeated incidence of a violation that previously resulted in a warning, or the first incidence of a + more serious violation. + 2) Consequence: A private, written warning with a time-limited cooldown period designed to underscore the + seriousness of the situation and give the community members involved time to process the incident. The cooldown + period may be limited to particular communication channels or interactions with particular community members. + 3) Repair: Examples of repair may include making an apology, using the cooldown period to reflect on actions and + impact, and being thoughtful about re-entering community spaces after the period is over. +3) Temporary Suspension + 1) Event: A pattern of repeated violation which the Community Moderators have tried to address with warnings, or a + single serious violation. + 2) Consequence: A private written warning with conditions for return from suspension. In general, temporary + suspensions give the person being suspended time to reflect upon their behavior and possible corrective actions. + 3) Repair: Examples of repair include respecting the spirit of the suspension, meeting the specified conditions for + return, and being thoughtful about how to reintegrate with the community when the suspension is lifted. +4) Permanent Ban + 1) Event: A pattern of repeated code of conduct violations that other steps on the ladder have failed to resolve, or + a violation so serious that the Community Moderators determine there is no way to keep the community safe with + this person as a member. + 2) Consequence: Access to all community spaces, tools, and communication channels is removed. In general, permanent + bans should be rarely used, should have strong reasoning behind them, and should only be resorted to if working + through other remedies has failed to change the behavior. + 3) Repair: There is no possible repair in cases of this severity. + +This enforcement ladder is intended as a guideline. It does not limit the ability of Community Managers to use their +discretion and judgment, in keeping with the best interests of our community. ## Scope -This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. - -## Enforcement - -Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at **magicalmixer06@gmail.com**. -All complaints will be reviewed and investigated promptly and fairly. - -All community leaders are obligated to respect the privacy and security of the reporter of any incident. +This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing +the community in public or other spaces. Examples of representing our community include using an official email address, +posting via an official social media account, or acting as an appointed representative at an online or offline event. ## Attribution -This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. +This Code of Conduct is adapted from the Contributor Covenant, version 3.0, permanently available +at [https://www.contributor-covenant.org/version/3/0/](https://www.contributor-covenant.org/version/3/0/). + +Contributor Covenant is stewarded by the Organization for Ethical Source and licensed under CC BY-SA 4.0. To view a copy +of this license, +visit [https://creativecommons.org/licenses/by-sa/4.0/](https://creativecommons.org/licenses/by-sa/4.0/) -[homepage]: https://www.contributor-covenant.org -[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +For answers to common questions about Contributor Covenant, see the FAQ +at [https://www.contributor-covenant.org/faq](https://www.contributor-covenant.org/faq). Translations are provided +at [https://www.contributor-covenant.org/translations](https://www.contributor-covenant.org/translations). Additional +enforcement and community guideline resources can be found +at [https://www.contributor-covenant.org/resources](https://www.contributor-covenant.org/resources). The enforcement +ladder was inspired by the work of [Mozilla’s code of conduct team](https://github.com/mozilla/inclusion). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index e63bfcfd..00000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,179 +0,0 @@ -# Contributing to SlideGenerator - -First off, thanks for taking the time to contribute! 🎉 - -The following is a set of guidelines for contributing to SlideGenerator. These are mostly guidelines, not rules. Use your best judgment, and feel free to propose changes to this document in a pull request. - -## Table of Contents - -- [Code of Conduct](#code-of-conduct) -- [How Can I Contribute?](#how-can-i-contribute) - - [Reporting Bugs](#reporting-bugs) - - [Suggesting Enhancements](#suggesting-enhancements) - - [Pull Requests](#pull-requests) -- [Development Guide](#development-guide) - - [Prerequisites](#prerequisites) - - [Get Source Code](#get-source-code) - - [Running](#running) - - [Building](#building) - - [Code Quality](#code-quality) -- [Documentation](#documentation) - -## Code of Conduct - -This project and everyone participating in it is governed by the [SlideGenerator Code of Conduct](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. Please report unacceptable behavior to the project maintainers. - -## How Can I Contribute? - -### Reporting Bugs - -This section guides you through submitting a bug report for SlideGenerator. Following these guidelines helps maintainers and the community understand your report, reproduce the behavior, and find related reports. - -- **Use a clear and descriptive title** for the issue to identify the problem. -- **Describe the exact steps to reproduce the problem** in as much detail as possible. -- **Include screenshots or GIFs** which show you following the reproduction steps. -- **Explain which behavior you expected to see instead and why.** - -### Suggesting Enhancements - -This section guides you through submitting an enhancement suggestion for SlideGenerator, including completely new features and minor improvements to existing functionality. - -- **Use a clear and descriptive title** for the issue to identify the suggestion. -- **Provide a step-by-step description of the suggested enhancement** in as much detail as possible. -- **Explain why this enhancement would be useful** to most SlideGenerator users. - -### Pull Requests - -The process is straightforward: - -1. **Fork** the repo on GitHub. -2. **Clone** the project to your own machine. -3. **Create a branch** for your feature or fix: `git checkout -b feature/amazing-feature`. -4. **Commit** your changes to your own branch. - * Make sure to follow the [Code Quality](#code-quality) guidelines. - * Write clear, descriptive commit messages. -5. **Push** your work back up to your fork. -6. **Submit a Pull Request** so that we can review your changes. - -## Development Guide - -### Prerequisites - -Ensure you have the following installed: - -- **.NET 10.0 SDK** or later ([Download](https://dotnet.microsoft.com/download)) -- **Node.js** (LTS version recommended) ([Download](https://nodejs.org/en/download)) - -We recommend using **Visual Studio** (for Backend) or **Visual Studio Code** (Full Stack) for the best development experience. - -### Get Source Code - -Clone the repository with submodules: - -```bash -git clone https://github.com/thnhmai06/SlideGenerator --recurse-submodules -cd SlideGenerator -``` - -To update an existing clone: - -```bash -git fetch -git pull -``` - -### Running - -#### Backend - -1. **Via Visual Studio:** - - Open `SlideGenerator.sln`. - - Set `SlideGenerator.Presentation` as the startup project. - - Start Debugging (F5). - -2. **Via VS Code:** - - Open the "Run and Debug" tab. - - Select `[Backend]` configuration and Start Debugging (F5). - -3. **Via CLI:** - ```bash - cd backend - dotnet run --project src/SlideGenerator.Presentation - ``` - -#### Frontend - -First, install dependencies: -```bash -cd frontend -npm install -``` - -1. **Via VS Code:** - - Select `[Frontend]` configuration and Start Debugging (F5). - -2. **Via CLI:** - ```bash - npm run dev - ``` - -### Building - -We use [Task](https://taskfile.dev/) (also known as `go-task`) as our build runner. It provides a consistent interface across platforms. - -**Prerequisites:** -- Install Task: [Installation Guide](https://taskfile.dev/installation/) - -**VS Code Integration:** -The project includes a `.vscode/tasks.json` that maps VS Code tasks to Taskfile commands. You can run them via the **Terminal -> Run Task** menu or by pressing `Ctrl+Shift+B` for a full build. - -**Build Commands:** - -Build everything (Backend + Frontend): -```bash -task build -``` - -Build components individually: -```bash -task build:backend -task build:frontend -``` - -Run tests: -```bash -task test -``` - -Specify target runtime (defaults to `win-x64`): -```bash -task build RUNTIME=linux-x64 -``` - -**Supported Runtimes:** `win-x64`, `linux-x64`, `linux-arm`, `linux-arm64`. - -### Code Quality - -Before committing, please run the formatters using Task: - -```bash -task format -``` - -This will run `dotnet format` for the backend and `npm run format` for the frontend. - -## Documentation - -**Backend:** -- [Overview](backend/README.md) -- [Architecture](backend/docs/en/architecture.md) -- [Job System](backend/docs/en/job-system.md) -- [SignalR API](backend/docs/en/signalr.md) -- [Configuration](backend/docs/en/configuration.md) -- [Usage](backend/docs/en/usage.md) - -**Frontend:** -- [Overview](frontend/docs/en/overview.md) -- [Usage](frontend/docs/en/usage.md) -- [Development](frontend/docs/en/development.md) -- [Build & Packaging](frontend/docs/en/build-and-packaging.md) \ No newline at end of file diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 00000000..ec80315d --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,22 @@ + + + $(NoWarn);NU1902;NU1903;NETSDK1206 + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + all + + + diff --git a/LICENSE b/LICENSE index f98191d2..261eeb9e 100644 --- a/LICENSE +++ b/LICENSE @@ -1,674 +1,201 @@ - GNU GENERAL PUBLIC LICENSE - Version 3, 29 June 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The GNU General Public License is a free, copyleft license for -software and other kinds of works. - - The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -the GNU General Public License is intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. We, the Free Software Foundation, use the -GNU General Public License for most of our software; it applies also to -any other work released this way by its authors. You can apply it to -your programs, too. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things. - - To protect your rights, we need to prevent others from denying you -these rights or asking you to surrender the rights. Therefore, you have -certain responsibilities if you distribute copies of the software, or if -you modify it: responsibilities to respect the freedom of others. - - For example, if you distribute copies of such a program, whether -gratis or for a fee, you must pass on to the recipients the same -freedoms that you received. You must make sure that they, too, receive -or can get the source code. And you must show them these terms so they -know their rights. - - Developers that use the GNU GPL protect your rights with two steps: -(1) assert copyright on the software, and (2) offer you this License -giving you legal permission to copy, distribute and/or modify it. - - For the developers' and authors' protection, the GPL clearly explains -that there is no warranty for this free software. For both users' and -authors' sake, the GPL requires that modified versions be marked as -changed, so that their problems will not be attributed erroneously to -authors of previous versions. - - Some devices are designed to deny users access to install or run -modified versions of the software inside them, although the manufacturer -can do so. This is fundamentally incompatible with the aim of -protecting users' freedom to change the software. The systematic -pattern of such abuse occurs in the area of products for individuals to -use, which is precisely where it is most unacceptable. Therefore, we -have designed this version of the GPL to prohibit the practice for those -products. If such problems arise substantially in other domains, we -stand ready to extend this provision to those domains in future versions -of the GPL, as needed to protect the freedom of users. - - Finally, every program is threatened constantly by software patents. -States should not allow patents to restrict development and use of -software on general-purpose computers, but in those that do, we wish to -avoid the special danger that patents applied to a free program could -make it effectively proprietary. To prevent this, the GPL assures that -patents cannot be used to render the program non-free. - - The precise terms and conditions for copying, distribution and -modification follow. - - TERMS AND CONDITIONS - - 0. Definitions. - - "This License" refers to version 3 of the GNU General Public License. - - "Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - - "The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - - To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - - A "covered work" means either the unmodified Program or a work based -on the Program. - - To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - - To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - - An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - - 1. Source Code. - - The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - - A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - - The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - - The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - - The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - - The Corresponding Source for a work in source code form is that -same work. - - 2. Basic Permissions. - - All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - - You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - - Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - - 3. Protecting Users' Legal Rights From Anti-Circumvention Law. - - No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - - When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - - 4. Conveying Verbatim Copies. - - You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - - You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - - 5. Conveying Modified Source Versions. - - You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - - A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - - 6. Conveying Non-Source Forms. - - You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - - A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - - A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, "normally used" refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. - - "Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made. - - If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - - The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network. - - Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - - 7. Additional Terms. - - "Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - - When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - - Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or - - e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors. - - All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - - If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - - Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way. - - 8. Termination. - - You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - - However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - - Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - - Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - - 9. Acceptance Not Required for Having Copies. - - You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - - 10. Automatic Licensing of Downstream Recipients. - - Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - - An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - - You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - - 11. Patents. - - A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - - A contributor's "essential patent claims" are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License. - - Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - - In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - - If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - - If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - - A patent license is "discriminatory" if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007. - - Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - - 12. No Surrender of Others' Freedom. - - If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program. - - 13. Use with the GNU Affero General Public License. - - Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU Affero General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the special requirements of the GNU Affero General Public License, -section 13, concerning interaction through a network will apply to the -combination as such. - - 14. Revised Versions of this License. - - The Free Software Foundation may publish revised and/or new versions of -the GNU General Public License from time to time. Such new versions will -be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - - Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU General -Public License "or any later version" applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU General Public License, you may choose any version ever published -by the Free Software Foundation. - - If the Program specifies that a proxy can decide which future -versions of the GNU General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program. - - Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version. - - 15. Disclaimer of Warranty. - - THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY -OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, -THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF -ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - - 16. Limitation of Liability. - - IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS -THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE -USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF -DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD -PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), -EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF -SUCH DAMAGES. - - 17. Interpretation of Sections 15 and 16. - - If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -state the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - - Copyright (C) 2026 - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . - -Also add information on how to contact you by electronic and paper mail. - - If the program does terminal interaction, make it output a short -notice like this when it starts in an interactive mode: - - Copyright (C) 2026 - This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. - This is free software, and you are welcome to redistribute it - under certain conditions; type `show c' for details. - -The hypothetical commands `show w' and `show c' should show the appropriate -parts of the General Public License. Of course, your program's commands -might be different; for a GUI interface, you would use an "about box". - - You should also get your employer (if you work as a programmer) or school, -if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU GPL, see -. - - The GNU General Public License does not permit incorporating your program -into proprietary programs. If your program is a subroutine library, you -may consider it more useful to permit linking proprietary applications with -the library. If this is what you want to do, use the GNU Lesser General -Public License instead of this License. But first, please read -. + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md deleted file mode 100644 index 75026bb5..00000000 --- a/README.md +++ /dev/null @@ -1,78 +0,0 @@ -
-

- Latest Release - Downloads - CodeFactorLines of CodeLicense -

-

Slide Generator

-

- An offline desktop tool to auto-generate PowerPoint slides from templates and spreadsheet data. -

-
Cross-platform, parallel-processing support, no Office required.
-
- -## Features - -- **Automated Slide Generation:** Instantly create PowerPoint presentations from Excel spreadsheets and PPTX templates. -- **Intelligent Image Processing:** - - **Smart ROI (Region of Interest) Strategies:** - - **Rule of Thirds (Face Focus):** Detect faces and align them with the "rule of thirds" grid for professional photographic composition. - - **Prominent (Saliency):** Automatically identifies and preserves the most visually striking or important region of the image. - - **Center:** Traditional center-point anchoring for standard layouts. - - **Precision Cropping Modes:** - - **Fit:** Automatically calculates the optimal aspect ratio and scales the image to fit perfectly within the target shape without distortion. - - **Crop:** Performs a direct cut based on the target dimensions for pixel-perfect results. -- **Cloud-Ready Data Handling:** - - **Auto-Resolve Cloud Links:** Supports direct image resolution from Google Drive, OneDrive, and Google Photos. - - **Automated Downloading:** Automatically fetches remote images during the generation process, eliminating manual downloads. -- **Offline & Private:** Runs 100% locally on your desktop. No internet connection required for core generation (Cloud features require temporary access). -- **No Office Needed:** Generates slides without requiring Microsoft Office or PowerPoint to be installed. -- **Robust Job Management:** - - **Real-time Monitoring:** Track progress and status of every job and sheet. - - **Control:** Pause, resume, cancel, or remove jobs at any time. - - **Resilience:** Automatically saves job state; keeps your progress safe even if the app closes unexpectedly. -- **Modern UI/UX:** - - Clean, responsive interface with Dark/Light theme support. - - Multi-language support (English, Vietnamese). -- **Performance:** - - Parallel processing for faster generation. - - Cross-platform support (Windows, Linux). - -## Installation - -### Prerequisites - -To run Slide Generator, you need to install the following runtime: - -- [ASP.NET Core 10 Runtime](https://dotnet.microsoft.com/en-us/download/dotnet/10.0/runtime) (Choose the **Run server apps** option). - -### Setup - -1. **Download:** Get the latest release compatible with your platform from the [Releases page](https://github.com/thnhmai06/SlideGenerator/releases/latest). -2. **Run:** Launch the application by running the executable file (Setup/Protable). - -## License - -This project is licensed under the GPL-3.0 License - see the [LICENSE](LICENSE) file for details. - -## Star History - - - - - - Star History Chart - - - -## Contributing - -We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) for details on how to set up the development environment, build the project, and submit changes. - -## Contributors - -| [
**thnhmai06**](https://github.com/thnhmai06) | [
**NAV-adsf23fd**](https://github.com/NAV-adsf23fd) | -| :------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------: | -| 👑 💻 | 🎨 | - -**Core Framework:** [SlideGenerator.Framework](https://github.com/thnhmai06/SlideGenerator.Framework) diff --git a/SlideGenerator.sln.DotSettings b/SlideGenerator.sln.DotSettings new file mode 100644 index 00000000..a4096eba --- /dev/null +++ b/SlideGenerator.sln.DotSettings @@ -0,0 +1,33 @@ + + + Copyright (C) ${CurrentDate.Year} {AUTHOR} + +Solution: ${File.SolutionName} +Project: ${File.ProjectName} +File: ${File.FileName} + +This file is part of this project. You can find the full source code here: {REPOSITORY_URL} + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, version 3. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + + True + <AssemblyExplorer> + <Assembly Path="C:\Users\haith\.nuget\packages\downloader\5.1.0\lib\net10.0\Downloader.dll" /> + <Assembly Path="V:\.cache\nuget\microsoft.visualstudio.validation\17.8.8\lib\net6.0\Microsoft.VisualStudio.Validation.dll" /> +</AssemblyExplorer> + True + <SessionState ContinuousTestingIsOn="True" ContinuousTestingMode="3" IsActive="True" Name="Continuous Testing" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <Solution /> +</SessionState> + True + True + True + True + \ No newline at end of file diff --git a/SlideGenerator.slnx b/SlideGenerator.slnx new file mode 100644 index 00000000..1e72a822 --- /dev/null +++ b/SlideGenerator.slnx @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Taskfile.yml b/Taskfile.yml index a7b9506b..b42bfb7b 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -1,89 +1,47 @@ -version: '3' +version: "3" vars: - RUNTIME: '{{default "win-x64" .RUNTIME}}' - BACKEND_BUILD_DIR: 'frontend/backend' - BACKEND_PROJECT: 'backend/src/SlideGenerator.Presentation/SlideGenerator.Presentation.csproj' - BACKEND_SOLUTION: 'backend/SlideGenerator.slnx' + BACKEND_SOLUTION: SlideGenerator.slnx + BACKEND_RESULTS_DIR: '{{.BACKEND_RESULTS_DIR | default "TestResults"}}' + BACKEND_RUN_PROJECT: src/SlideGenerator.Stdio/SlideGenerator.Stdio.csproj + +env: + DOTNET_NOLOGO: "true" + DOTNET_CLI_TELEMETRY_OPTOUT: "1" + SYNCFUSION_LICENSE_KEY: '{{.SYNCFUSION_LICENSE_KEY | default "empty"}}' tasks: - default: + restore: + desc: Restore backend NuGet packages. + dir: "{{.TASKFILE_DIR}}" cmds: - - task: build + - dotnet restore {{.BACKEND_SOLUTION}} - # ============================================================================ - # BUILD TASKS - # ============================================================================ build: - desc: Build the full application (Backend + Frontend) - cmds: - - task: build:backend - - task: build:frontend - - build:backend: - desc: Build and publish Backend to frontend resources + desc: Build backend solution (Debug). + dir: "{{.TASKFILE_DIR}}" + deps: + - restore cmds: - - echo "Building Backend for {{.RUNTIME}}..." - - dotnet publish {{.BACKEND_PROJECT}} -c Release -r {{.RUNTIME}} -o {{.BACKEND_BUILD_DIR}} --self-contained false + - dotnet build {{.BACKEND_SOLUTION}} --no-restore - build:frontend: - desc: Build the Frontend - dir: frontend + run: + desc: Run backend stdio sidecar (Debug). + dir: "{{.TASKFILE_DIR}}" + deps: + - restore cmds: - - echo "Building Frontend..." - - npm install - - npm run build - env: - ELECTRON_BUILDER_PUBLISH: never - - # ============================================================================ - # TEST TASKS - # ============================================================================ + - dotnet run --project {{.BACKEND_RUN_PROJECT}} test: - desc: Run all tests - cmds: - - task: test:backend - - task: test:frontend - - test:backend: - desc: Run Backend unit tests - 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 - - test:frontend: - desc: Run Frontend unit tests - dir: frontend - cmds: - - echo "Running Frontend Tests..." - - npm install - - mkdir -p test-results - - npm test -- --run --reporter=default --reporter=junit --outputFile=./test-results/junit.xml - - # ============================================================================ - # MAINTENANCE TASKS - # ============================================================================ - format: - desc: Format code for both Backend and Frontend - cmds: - - task: format:backend - - task: format:frontend - - format:backend: - dir: backend - cmds: - - dotnet format - - format:frontend: - dir: frontend + desc: Run backend tests (Debug) and write TRX results. + dir: "{{.TASKFILE_DIR}}" + deps: + - restore cmds: - - npm run format + - dotnet test {{.BACKEND_SOLUTION}} --no-restore --report-trx --results-directory {{.BACKEND_RESULTS_DIR}} - clean: - desc: Clean build artifacts + apply-copyright: + desc: Apply backend copyright headers. + dir: "{{.TASKFILE_DIR}}" cmds: - - cmd: rm -rf frontend/backend frontend/dist frontend/release backend/TestResults - platforms: [linux, darwin] - - cmd: Remove-Item -Recurse -Force -ErrorAction SilentlyContinue frontend/backend, frontend/dist, frontend/release, backend/TestResults - platforms: [windows] + - dotnet run --project scripts/ApplyCopyright/ApplyCopyright.csproj diff --git a/assets/logo/app-icon.png b/assets/logo/app-icon.png new file mode 100644 index 00000000..0a19b91a Binary files /dev/null and b/assets/logo/app-icon.png differ diff --git a/assets/logo/app-logo.png b/assets/logo/app-logo.png new file mode 100644 index 00000000..cffd30f8 Binary files /dev/null and b/assets/logo/app-logo.png differ diff --git a/assets/logo/app-name.png b/assets/logo/app-name.png new file mode 100644 index 00000000..268e7e69 Binary files /dev/null and b/assets/logo/app-name.png differ diff --git a/assets/logo/ico/app-icon.ico b/assets/logo/ico/app-icon.ico new file mode 100644 index 00000000..1a4a2b47 Binary files /dev/null and b/assets/logo/ico/app-icon.ico differ diff --git a/assets/logo/ico/app-icon.png b/assets/logo/ico/app-icon.png new file mode 100644 index 00000000..d082bd6c Binary files /dev/null and b/assets/logo/ico/app-icon.png differ diff --git a/assets/logo/ico/app-icon.svg b/assets/logo/ico/app-icon.svg new file mode 100644 index 00000000..ed2cc75c --- /dev/null +++ b/assets/logo/ico/app-icon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/backend/.dockerignore b/backend/.dockerignore deleted file mode 100644 index fe1152bd..00000000 --- a/backend/.dockerignore +++ /dev/null @@ -1,30 +0,0 @@ -**/.classpath -**/.dockerignore -**/.env -**/.git -**/.gitignore -**/.project -**/.settings -**/.toolstarget -**/.vs -**/.vscode -**/*.*proj.user -**/*.dbmdl -**/*.jfm -**/azds.yaml -**/bin -**/charts -**/docker-compose* -**/Dockerfile* -**/node_modules -**/npm-debug.log -**/obj -**/secrets.dev.yaml -**/values.dev.yaml -LICENSE -README.md -!**/.gitignore -!.git/HEAD -!.git/config -!.git/packed-refs -!.git/refs/heads/** \ No newline at end of file diff --git a/backend/.gitignore b/backend/.gitignore deleted file mode 100644 index 1141bbc3..00000000 --- a/backend/.gitignore +++ /dev/null @@ -1,60 +0,0 @@ -## A streamlined .gitignore for modern .NET projects -## including temporary files, build results, and -## files generated by popular .NET tools. If you are -## developing with Visual Studio, the VS .gitignore -## https://github.com/github/gitignore/blob/main/VisualStudio.gitignore -## has more thorough IDE-specific entries. -## -## Get latest from https://github.com/github/gitignore/blob/main/Dotnet.gitignore - -# Build results -[Dd]ebug/ -[Dd]ebugPublic/ -[Rr]elease/ -[Rr]eleases/ -x64/ -x86/ -[Ww][Ii][Nn]32/ -[Aa][Rr][Mm]/ -[Aa][Rr][Mm]64/ -bld/ -[Bb]in/ -[Oo]bj/ -[Ll]og/ -[Ll]ogs/ - -# .NET Core -project.lock.json -project.fragment.lock.json -artifacts/ - -# ASP.NET Scaffolding -ScaffoldingReadMe.txt - -# NuGet Packages -*.nupkg -# NuGet Symbol Packages -*.snupkg - -# dotenv environment variables file -.env - -# Others -~$* -*~ -CodeCoverage/ - -# MSBuild Binary and Structured Log -*.binlog - -# MSTest test Results -[Tt]est[Rr]esult*/ -[Bb]uild[Ll]og.* - -# NUnit -*.VisualState.xml -TestResult.xml -nunit-*.xml - -# Custom -*.config.yaml \ No newline at end of file diff --git a/backend/Directory.Build.props b/backend/Directory.Build.props deleted file mode 100644 index 3d0028f7..00000000 --- a/backend/Directory.Build.props +++ /dev/null @@ -1,5 +0,0 @@ - - - $(NoWarn);NU1902;NU1903;NETSDK1206 - - diff --git a/backend/README.md b/backend/README.md deleted file mode 100644 index 4aecfc2b..00000000 --- a/backend/README.md +++ /dev/null @@ -1,91 +0,0 @@ -# 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. - -## Table of Contents - -- [SlideGenerator Backend](#slidegenerator-backend) - - [Table of Contents](#table-of-contents) - - [Overview](#overview) - - [Architecture](#architecture) - - [Key Systems](#key-systems) - - [Job System](#job-system) - - [SignalR API](#signalr-api) - - [Getting Started](#getting-started) - - [Configuration](#configuration) - - [Usage](#usage) - - [Development Guide](#development-guide) - - [Development](#development) - - [Deployment](#deployment) - - [Framework Library](#framework-library) - -## Overview - -This directory contains the backend source code, structured as a Clean Architecture solution. - -- **Target Runtime:** .NET 10 -- **Host:** ASP.NET Core Web API + SignalR -- **Background Jobs:** Hangfire (Persistent job execution) -- **Database:** SQLite (Job state storage) -- **Architectural Pattern:** Clean Architecture (Domain, Application, Infrastructure, Presentation) - -## Architecture - -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) - -## Key Systems - -### Job System - -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) - -### SignalR API - -Real-time bi-directional communication with the Frontend. - -- **Protocol:** WebSocket (primary) -- **Features:** Real-time progress updates, Job control commands, Configuration sync. -- **Learn more:** [SignalR API Documentation](docs/en/signalr.md) - -## Getting Started - -### Configuration - -Customize server settings, job concurrency limits, and image processing parameters. - -- **Guide:** [Configuration Guide](docs/en/configuration.md) - -### Usage - -How to run, interact, and troubleshoot the backend service. - -- **Guide:** [Usage Guide](docs/en/usage.md) - -## Development Guide - -### Development - -Setup your environment, run the server locally, and run tests. - -- **Guide:** [Development Guide](docs/en/development.md) - -### Deployment - -How to publish and deploy the backend for production (Windows/Linux). - -- **Guide:** [Deployment Guide](docs/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) - ---- - -[🇻🇳 Vietnamese Documentation](docs/vi) diff --git a/backend/SlideGenerator.slnx b/backend/SlideGenerator.slnx deleted file mode 100644 index 28fc9562..00000000 --- a/backend/SlideGenerator.slnx +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/backend/backend.config.sample.yaml b/backend/backend.config.sample.yaml deleted file mode 100644 index a547c73f..00000000 --- a/backend/backend.config.sample.yaml +++ /dev/null @@ -1,48 +0,0 @@ -# SlideGenerator Backend Configuration -# This file configures the backend server and image processing options - -# Server configuration -server: - host: 127.0.0.1 - port: 5000 - debug: false - -# Download configuration -download: - max_chunks: 5 - limit_bytes_per_second: 0 - save_folder: ./downloads - retry: - timeout: 30 - max_retries: 3 - proxy: - use_proxy: false - proxy_address: '' - username: '' - password: '' - domain: '' - -# Job configuration -job: - max_concurrent_jobs: 2 - -# Image processing configuration -image: - # Face detection settings - face: - # Minimum confidence score (0-1) for face detection - confidence: 0.6 - - # If true, union all detected faces; if false, pick the best single face - union_all: true - - # Maximum dimension for face detection image. 0 = unlimited. Default is 1280. - max_dimension: 1280 - - # Saliency detection settings - saliency: - # Padding ratios around saliency anchor (0-1) - padding_top: 0.0 - padding_bottom: 0.0 - padding_left: 0.0 - padding_right: 0.0 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/configuration.md b/backend/docs/en/configuration.md deleted file mode 100644 index b3a256de..00000000 --- a/backend/docs/en/configuration.md +++ /dev/null @@ -1,50 +0,0 @@ -# Configuration - -[🇻🇳 Vietnamese Version](../vi/configuration.md) - -The backend is configured via a YAML file named `backend.config.yaml` located in the working directory. - -## Configuration File - -On the first run, if the file is missing, the application will generate a default `backend.config.yaml`. You can also use `backend.config.sample.yaml` as a reference. - -### Structure & Key Settings - -```yaml -server: - host: "localhost" - port: 5000 - debug: false # Enable detailed debug logging - -job: - # Maximum number of sheet jobs running in parallel across all groups. - maxConcurrentJobs: 4 - -image: - # Face detection confidence threshold (0.0 - 1.0) - faceConfidence: 0.7 - # Max dimension for image resizing (0 = unlimited) - maxDimension: 1280 - # Padding added to detected regions of interest - saliencyPadding: 0.1 - -download: - # Network settings for downloading remote images - maxBandwidth: 0 # 0 = unlimited - retryCount: 3 -``` - -## Runtime Behavior - -### Persistence -- **Job State:** Stored in a SQLite database (`jobs.db` by default). This allows the application to resume tasks after a restart. -- **Worker Pool:** The number of background processing threads is automatically adjusted based on `job.maxConcurrentJobs`. - -### Safety Mechanisms - -To ensure data integrity, the system enforces the following rules regarding configuration changes: - -1. **Blocked Updates:** You cannot change configuration settings while any job group is in `Pending` or `Running` state. -2. **Allowed Updates:** Configuration can be safely updated when all active jobs are `Paused` or when there are no active jobs. - -Next: [Job System](job-system.md) diff --git a/backend/docs/en/deployment.md b/backend/docs/en/deployment.md deleted file mode 100644 index 31c938ff..00000000 --- a/backend/docs/en/deployment.md +++ /dev/null @@ -1,22 +0,0 @@ -# Deployment - -Vietnamese version: [Vietnamese](../vi/deployment.md) - -## Summary - -The backend is an ASP.NET Core app hosted by `SlideGenerator.Presentation`. - -## Steps - -1. Prepare `backend.config.yaml` (host, port, maxConcurrentJobs). -2. Ensure write access for: - - the config file location, - - the Hangfire SQLite path, - - output folders. -3. Run the server (local or published build). - -## Notes - -- Default health check: `/health`. -- Hangfire dashboard: `/hangfire` (read-only). -- The server is designed for local/offline usage. diff --git a/backend/docs/en/development.md b/backend/docs/en/development.md deleted file mode 100644 index 0afd501b..00000000 --- a/backend/docs/en/development.md +++ /dev/null @@ -1,30 +0,0 @@ -# Development - -Vietnamese version: [Vietnamese](../vi/development.md) - -## Build and run - -From `backend/`: - -- Build: `dotnet build` -- Run: `dotnet run --project src/SlideGenerator.Presentation` - -## Code structure - -Feature-based slices live across layers: - -- Presentation: `src/SlideGenerator.Presentation/Features/*/*Hub.cs` -- 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. -- `Infrastructure/Features/Jobs`: Hangfire executor, state store, collections. - -## Testing - -- Tests live under `backend/tests`. -- Use `dotnet test` to run the suite. 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/en/usage.md b/backend/docs/en/usage.md deleted file mode 100644 index 43d77ee5..00000000 --- a/backend/docs/en/usage.md +++ /dev/null @@ -1,55 +0,0 @@ -# Usage - -Vietnamese version: [Vietnamese](../vi/usage.md) - -## Run the backend - -From `backend/`: - -``` -dotnet run --project src/SlideGenerator.Presentation -``` - -## Verify - -- Health check: `GET /health` -- Hangfire dashboard: `/hangfire` - -## Connect from the client - -- Job hub: `/hubs/job` (alias: `/hubs/task`) -- Sheet hub: `/hubs/sheet` -- Config hub: `/hubs/config` - -## Quick examples - -Create a group job: - -```json -{ - "type": "JobCreate", - "jobType": "Group", - "templatePath": "C:\\slides\\template.pptx", - "spreadsheetPath": "C:\\data\\book.xlsx", - "outputPath": "C:\\output", - "sheetNames": ["Sheet1"] -} -``` - -Pause a job: - -```json -{ "type": "JobControl", "jobId": "TASK_ID", "jobType": "Group", "action": "Pause" } -``` - -Remove a group (also deletes backend state): - -```json -{ "type": "JobControl", "jobId": "TASK_ID", "jobType": "Group", "action": "Remove" } -``` - -Query active jobs: - -```json -{ "type": "JobQuery", "scope": "Active" } -``` diff --git a/backend/docs/vi/architecture.md b/backend/docs/vi/architecture.md deleted file mode 100644 index 8e443bb6..00000000 --- a/backend/docs/vi/architecture.md +++ /dev/null @@ -1,68 +0,0 @@ -# Kiến trúc Hệ thống - -[🇺🇸 English Version](../en/architecture.md) - -## Tổng quan - -Backend được xây dựng dựa trên nguyên lý **Clean Architecture** (Kiến trúc Sạch), đảm bảo sự phân tách rõ ràng giữa các mối quan tâm (separation of concerns). Thiết kế này cho phép logic nghiệp vụ cốt lõi độc lập hoàn toàn với các framework, cơ sở dữ liệu và giao diện bên ngoài. - -## Kiến trúc Phân tầng - -Giải pháp được chia thành bốn tầng đồng tâm: - -```mermaid -graph TD - Presentation --> Application - Application --> Domain - Infrastructure --> Application - Infrastructure --> Domain - Presentation --> Infrastructure -``` - -### 1. Tầng Domain (`SlideGenerator.Domain`) -**Cốt lõi.** Chứa các quy tắc nghiệp vụ và thực thể doanh nghiệp. -- **Phụ thuộc:** Không có. -- **Thành phần:** - - `Entities`: Các đối tượng cốt lõi như `JobGroup`, `JobSheet`. - - `Enums`: `JobStatus`, `JobType`. - - `ValueObjects`: Các định danh bất biến. - - `Constants`: Các hằng số bất biến của hệ thống. - -### 2. Tầng Application (`SlideGenerator.Application`) -**Người điều phối.** Chứa các quy tắc nghiệp vụ đặc thù của ứng dụng. -- **Phụ thuộc:** Domain. -- **Thành phần:** - - `Interfaces`: Hợp đồng giao tiếp cho tầng Infrastructure (ví dụ: `IJobStore`, `IFileService`). - - `DTOs`: Đối tượng chuyển dữ liệu dùng cho giao tiếp API. - - `Services`: Các dịch vụ logic nghiệp vụ (ví dụ: `JobManager`). - - `Features`: Các bộ xử lý theo phong cách CQRS (nếu áp dụng). - -### 3. Tầng Infrastructure (`SlideGenerator.Infrastructure`) -**Bộ chuyển đổi.** Triển khai các interface được định nghĩa ở tầng Application. -- **Phụ thuộc:** Application, Domain. -- **Thành phần:** - - `Hangfire`: Xử lý công việc nền và lưu trữ trạng thái. - - `SQLite`: Triển khai lưu trữ dữ liệu vật lý. - - `FileSystem`: Các thao tác I/O (đọc/ghi file). - - `Logging`: Tích hợp Serilog. - -### 4. Tầng Presentation (`SlideGenerator.Presentation`) -**Đ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`). - - `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. -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`. - -Tiếp theo: [SignalR API](signalr.md) diff --git a/backend/docs/vi/configuration.md b/backend/docs/vi/configuration.md deleted file mode 100644 index ecb6eb2a..00000000 --- a/backend/docs/vi/configuration.md +++ /dev/null @@ -1,50 +0,0 @@ -# Cấu hình - -[🇺🇸 English Version](../en/configuration.md) - -Backend được cấu hình thông qua một file YAML có tên `backend.config.yaml` nằm trong thư mục làm việc. - -## File Cấu hình - -Trong lần chạy đầu tiên, nếu file này bị thiếu, ứng dụng sẽ tự động sinh ra một file `backend.config.yaml` mặc định. Bạn cũng có thể tham khảo file `backend.config.sample.yaml`. - -### Cấu trúc & Các cài đặt chính - -```yaml -server: - host: "localhost" - port: 5000 - debug: false # Bật log debug chi tiết - -job: - # Số lượng sheet job tối đa chạy song song trên tất cả các group. - maxConcurrentJobs: 4 - -image: - # Ngưỡng tin cậy khi nhận diện khuôn mặt (0.0 - 1.0) - faceConfidence: 0.7 - # Kích thước tối đa để resize ảnh (0 = không giới hạn) - maxDimension: 1280 - # Phần đệm (padding) thêm vào vùng ROI được phát hiện - saliencyPadding: 0.1 - -download: - # Giới hạn băng thông mạng khi tải ảnh (0 = không giới hạn) - maxBandwidth: 0 - retryCount: 3 -``` - -## Hành vi Runtime - -### Bền vững (Persistence) -- **Trạng thái Job:** Được lưu trong cơ sở dữ liệu SQLite (`jobs.db` mặc định). Điều này cho phép ứng dụng tiếp tục các tác vụ sau khi khởi động lại. -- **Worker Pool:** Số lượng luồng xử lý nền được tự động điều chỉnh dựa trên `job.maxConcurrentJobs`. - -### Cơ chế An toàn - -Để đảm bảo tính toàn vẹn dữ liệu, hệ thống áp dụng các quy tắc sau đối với việc thay đổi cấu hình: - -1. **Chặn cập nhật:** Bạn không thể thay đổi cấu hình khi có bất kỳ job group nào đang ở trạng thái `Pending` hoặc `Running`. -2. **Cho phép cập nhật:** Cấu hình có thể được cập nhật an toàn khi tất cả các job đang `Paused` hoặc khi không có job nào đang hoạt động. - -Tiếp theo: [Hệ thống Job](job-system.md) diff --git a/backend/docs/vi/deployment.md b/backend/docs/vi/deployment.md deleted file mode 100644 index e0ef8952..00000000 --- a/backend/docs/vi/deployment.md +++ /dev/null @@ -1,22 +0,0 @@ -# Triển khai - -English version: [English](../en/deployment.md) - -## Tóm tắt - -Backend là ứng dụng ASP.NET Core chạy từ `SlideGenerator.Presentation`. - -## Các bước - -1. Chuẩn bị `backend.config.yaml` (host, port, maxConcurrentJobs). -2. Đảm bảo quyền ghi cho: - - vị trí file config, - - file SQLite của Hangfire, - - thư mục output. -3. Chạy server (local hoặc bản publish). - -## Ghi chú - -- Health check: `/health`. -- Hangfire dashboard: `/hangfire` (read-only). -- Thiết kế ưu tiên chạy local/offline. diff --git a/backend/docs/vi/development.md b/backend/docs/vi/development.md deleted file mode 100644 index c381d085..00000000 --- a/backend/docs/vi/development.md +++ /dev/null @@ -1,30 +0,0 @@ -# Phát triển - -English version: [English](../en/development.md) - -## Build và chạy - -Từ thư mục `backend/`: - -- Build: `dotnet build` -- Run: `dotnet run --project src/SlideGenerator.Presentation` - -## Cấu trúc code - -Code chia theo feature ở các layer: - -- Presentation: `src/SlideGenerator.Presentation/Features/*/*Hub.cs` -- 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. -- `Infrastructure/Features/Jobs`: executor, state store, collections. - -## Testing - -- Test nằm trong `backend/tests`. -- Chạy bằng `dotnet test`. diff --git a/backend/docs/vi/job-system.md b/backend/docs/vi/job-system.md deleted file mode 100644 index ff0da062..00000000 --- a/backend/docs/vi/job-system.md +++ /dev/null @@ -1,91 +0,0 @@ -# Hệ thống Job - -[🇺🇸 English Version](../en/job-system.md) - -Hệ thống Job là động cơ cốt lõi của SlideGenerator, chịu trách nhiệm quản lý vòng đời của các tác vụ tạo slide. Nó hỗ trợ các quy trình phức tạp bao gồm gom nhóm (grouping), tạm dừng, tiếp tục và khôi phục sau sự cố. - -## Các khái niệm - -### Phân cấp Job - -Hệ thống sử dụng mẫu Composite để quản lý các job: - -1. **Group Job (`JobGroup`)**: Container gốc. Đại diện cho một yêu cầu từ người dùng (một Workbook + một Template). - * Chứa nhiều **Sheet Jobs**. - * Quản lý tài nguyên chung (parse template, thư mục đầu ra). -2. **Sheet Job (`JobSheet`)**: Đơn vị công việc nhỏ nhất. Đại diện cho việc tạo ra một file đầu ra từ một worksheet. - -### Trạng thái Job - -Một job chuyển đổi qua các trạng thái sau: - -- **Pending (Chờ):** Đang xếp hàng chờ tài nguyên thực thi. -- **Processing (Đang xử lý):** Đang chạy (đọc dữ liệu hoặc render slide). -- **Paused (Tạm dừng):** Người dùng tạm dừng. Trạng thái được bảo lưu. -- **Done (Hoàn thành):** Kết thúc thành công. -- **Cancelled (Đã hủy):** Người dùng yêu cầu dừng. -- **Error (Lỗi):** Thất bại do có ngoại lệ (exception). - -### Sơ đồ Trạng thái - -```mermaid -stateDiagram-v2 - [*] --> Pending - Pending --> Processing: Scheduler chọn - Processing --> Paused: User Pause - Paused --> Processing: User Resume - Processing --> Done: Thành công - Processing --> Error: Ngoại lệ - Processing --> Cancelled: User Cancel - Paused --> Cancelled: User Cancel - Pending --> Cancelled: User Cancel -``` - -## Bộ sưu tập & Lưu trữ - -`JobManager` điều phối job thông qua hai bộ sưu tập (collection) chính: - -1. **Active Collection (Đang hoạt động):** - * **Lưu trữ:** In-memory `ConcurrentDictionary`. - * **Nội dung:** Các job đang `Pending`, `Processing`, hoặc `Paused`. - * **Bền vững:** Trạng thái được đồng bộ liên tục xuống SQLite qua `HangfireJobStateStore`. -2. **Completed Collection (Đã hoàn thành):** - * **Lưu trữ:** In-memory (cache) + SQLite (lưu trữ lâu dài). - * **Nội dung:** Các job đã `Done`, `Failed`, hoặc `Cancelled`. - -### Khôi phục sự cố (Crash Recovery) -Hệ thống được thiết kế để có khả năng phục hồi cao. -- **Lưu trạng thái:** Mọi thay đổi trạng thái và cập nhật tiến độ đều được ghi xuống cơ sở dữ liệu SQLite cục bộ. -- **Khôi phục:** Khi ứng dụng khởi động lại, hệ thống sẽ tải các job chưa hoàn thành từ database. - - Các job đang `Processing` sẽ bị chuyển về `Paused` để tránh tranh chấp tài nguyên ngay lập tức. - - Các job `Pending` vẫn giữ nguyên là `Pending`. - -## 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. -- 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**. - -### 2. Thực thi -- Nếu `AutoStart` được bật, các job sẽ được đẩy vào hàng đợi Hangfire. -- **Kiểm soát đồng thời:** Hệ thống tuân thủ cấu hình `job.maxConcurrentJobs` để giới hạn số lượng xử lý song song. -- **Chiến lược Resume:** Khi tiếp tục, hệ thống ưu tiên điền vào các slot trống bằng các job đang tạm dừng trước khi bắt đầu job mới đang chờ. - -### 3. Xử lý (Processing) -- **Bước 1:** Tải Template & Dữ liệu. -- **Bước 2:** Xử lý thay thế (Text & Ảnh). -- **Bước 3:** Render Slide. -- **Bước 4:** Lưu xuống đường dẫn đầu ra. - -### 4. Hoàn tất -- Khi một `JobSheet` xong, nó cập nhật trạng thái của mình. -- Khi **tất cả** `JobSheet` trong một `JobGroup` xong, Group chuyển sang trạng thái `Completed` và được di chuyển sang **Completed Collection**. - -## Mô hình Đồng thời - -- **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) - diff --git a/backend/docs/vi/signalr.md b/backend/docs/vi/signalr.md deleted file mode 100644 index ae3cd7af..00000000 --- a/backend/docs/vi/signalr.md +++ /dev/null @@ -1,126 +0,0 @@ -# SignalR API - -[🇺🇸 English Version](../en/signalr.md) - -Backend cung cấp một API thời gian thực thông qua SignalR hubs. Mọi giao tiếp đều tuân theo mẫu request/response kèm theo các thông báo (notification) bất đồng bộ. - -## Các Hub Endpoint - -| Endpoint | Mô tả | -| :--- | :--- | -| `/hubs/job` | Endpoint chính để tạo, điều khiển và truy vấn job. | -| `/hubs/sheet` | Tiện ích để kiểm tra Excel workbook (tiêu đề, dòng dữ liệu). | -| `/hubs/config` | Đọc và ghi cấu hình backend. | - -> **Lưu ý:** `/hubs/task` là alias cũ (legacy) của `/hubs/job`. - -## Giao thức - -### Mẫu Request -Client gửi yêu cầu bằng cách gọi phương thức `ProcessRequest` trên Hub với payload JSON. - -- **Trường bắt buộc:** `type` (chuỗi ký tự, không phân biệt hoa thường). -- **Phản hồi:** Được gửi lại qua sự kiện `ReceiveResponse`. -- **Lỗi:** Trả về message với type là `error`. - -## Job Hub Messages (`/hubs/job`) - -### 1. Tạo Job (`JobCreate`) - -Tạo một tác vụ tạo slide mới. - -**Group Job (Workbook + Template):** -```json -{ - "type": "JobCreate", - "jobType": "Group", - "templatePath": "C:\\slides\\template.pptx", - "spreadsheetPath": "C:\\data\\book.xlsx", - "outputPath": "C:\\output", - "sheetNames": ["Sheet1", "Sheet2"], - "textConfigs": [ - { "pattern": "{{Name}}", "columns": ["FullName"] } - ], - "imageConfigs": [ - { - "shapeId": 4, - "columns": ["Photo"], - "roiType": "RuleOfThirds", - "cropType": "Fit" - } - ], - "autoStart": true -} -``` - -**Sheet Job (Single Sheet):** -```json -{ - "type": "JobCreate", - "jobType": "Sheet", - "templatePath": "C:\\slides\\template.pptx", - "spreadsheetPath": "C:\\data\\book.xlsx", - "outputPath": "C:\\output\\Sheet1.pptx", - "sheetName": "Sheet1" -} -``` - -### 2. Điều khiển Job (`JobControl`) - -Quản lý trạng thái của các job đang chạy. - -- **Hành động:** `Pause`, `Resume`, `Cancel`, `Stop` (giống Cancel), `Remove` (xóa khỏi lịch sử). - -```json -{ - "type": "JobControl", - "jobId": "GUID-ID-HERE", - "jobType": "Group", - "action": "Pause" -} -``` - -### 3. Truy vấn Job (`JobQuery`) - -Lấy chi tiết job. - -- **Phạm vi (Scope):** `Active`, `Completed`, `All`. -- **includePayload:** Trả về JSON payload gốc (được tái tạo từ DB). - -```json -{ - "type": "JobQuery", - "jobId": "GUID-ID-HERE", - "jobType": "Group", - "includeSheets": true -} -``` - -### 4. Quét Template (Scan Template) -Các tiện ích để kiểm tra file PPTX. -- **Hành động:** `ScanShapes`, `ScanPlaceholders`, `ScanTemplate`. - -```json -{ - "type": "ScanShapes", - "filePath": "C:\\slides\\template.pptx" -} -``` - -## Thông báo (Notifications) - -Client phải lắng nghe sự kiện `ReceiveNotification` để nhận cập nhật thời gian thực. - -**Loại sự kiện:** -- `GroupProgress`: Tiến độ tổng thể của một group. -- `SheetProgress`: Tiến độ của một sheet đơn lẻ. -- `JobStatus`: Thay đổi trạng thái (ví dụ: Pending -> Processing). -- `LogEvent`: Log message có cấu trúc từ backend. - -## Đăng ký (Subscriptions) - -Để nhận cập nhật chi tiết cho các job cụ thể, client cần đăng ký: - -- `SubscribeGroup(groupId)` -- `SubscribeSheet(sheetId)` - diff --git a/backend/docs/vi/usage.md b/backend/docs/vi/usage.md deleted file mode 100644 index 8f6cf89e..00000000 --- a/backend/docs/vi/usage.md +++ /dev/null @@ -1,56 +0,0 @@ -# Sử dụng - -English version: [English](../en/usage.md) - -## Chạy backend - -Từ thư mục `backend/`: - -``` -dotnet run --project src/SlideGenerator.Presentation -``` - -## Kiểm tra - -- Health check: `GET /health` -- Hangfire dashboard: `/hangfire` - -## Kết nối từ client - -- Job hub: `/hubs/job` (alias: `/hubs/task`) -- Sheet hub: `/hubs/sheet` -- Config hub: `/hubs/config` - -## Ví dụ nhanh - -Tạo group job: - -```json -{ - "type": "JobCreate", - "jobType": "Group", - "templatePath": "C:\\slides\\template.pptx", - "spreadsheetPath": "C:\\data\\book.xlsx", - "outputPath": "C:\\output", - "sheetNames": ["Sheet1"] -} -``` - -Tạm dừng job: - -```json -{ "type": "JobControl", "jobId": "TASK_ID", "jobType": "Group", "action": "Pause" } -``` - -Xóa group (xóa cả backend state): - -```json -{ "type": "JobControl", "jobId": "TASK_ID", "jobType": "Group", "action": "Remove" } -``` - -Query job đang chạy: - -```json -{ "type": "JobQuery", "scope": "Active" } -``` - 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/Dockerfile b/backend/src/SlideGenerator.Presentation/Dockerfile deleted file mode 100644 index 9421c543..00000000 --- a/backend/src/SlideGenerator.Presentation/Dockerfile +++ /dev/null @@ -1,29 +0,0 @@ -# See https://aka.ms/customizecontainer to learn how to customize your debug container and how Visual Studio uses this Dockerfile to build your images for faster debugging. - -# This stage is used when running from VS in fast mode (Default for Debug configuration) -FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base -USER $APP_UID -WORKDIR /app -EXPOSE 8080 -EXPOSE 8081 - -# This stage is used to build the service project -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 . . -WORKDIR "/SlideGenerator.Presentation" -RUN dotnet build "./SlideGenerator.Presentation.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 - -# 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 diff --git a/backend/src/SlideGenerator.Presentation/Features/Configs/ConfigHub.cs b/backend/src/SlideGenerator.Presentation/Features/Configs/ConfigHub.cs deleted file mode 100644 index a235da36..00000000 --- a/backend/src/SlideGenerator.Presentation/Features/Configs/ConfigHub.cs +++ /dev/null @@ -1,231 +0,0 @@ -using System.Text.Json; -using Microsoft.AspNetCore.SignalR; -using SlideGenerator.Application.Common.Base.DTOs.Responses; -using SlideGenerator.Application.Features.Configs; -using SlideGenerator.Application.Features.Configs.DTOs.Components; -using SlideGenerator.Application.Features.Configs.DTOs.Requests; -using SlideGenerator.Application.Features.Configs.DTOs.Responses.Errors; -using SlideGenerator.Application.Features.Configs.DTOs.Responses.Successes; -using SlideGenerator.Application.Features.Images; -using SlideGenerator.Application.Features.Jobs.Contracts; -using SlideGenerator.Domain.Configs; -using SlideGenerator.Domain.Features.Jobs.Enums; -using SlideGenerator.Infrastructure.Features.Configs; -using HubBase = SlideGenerator.Presentation.Common.Hubs.Hub; - -namespace SlideGenerator.Presentation.Features.Configs; - -/// -/// SignalR hub for configuration management. -/// -public class ConfigHub( - IJobManager jobManager, - IImageService imageService, - ILogger logger) : HubBase -{ - /// - public override async Task OnConnectedAsync() - { - logger.LogInformation("Client connected: {ConnectionId}", Context.ConnectionId); - await base.OnConnectedAsync(); - } - - /// - public override async Task OnDisconnectedAsync(Exception? exception) - { - logger.LogInformation("Client disconnected: {ConnectionId}", Context.ConnectionId); - await base.OnDisconnectedAsync(exception); - } - - public async Task ProcessRequest(JsonElement message) - { - Response response; - - try - { - var typeStr = message.GetProperty("type").GetString()?.ToLowerInvariant(); - - response = typeStr switch - { - "get" => ExecuteGetConfig(), - "update" => ExecuteUpdateConfig(Deserialize(message)), - "reload" => ExecuteReloadConfig(), - "reset" => ExecuteResetConfig(), - "modelstatus" => ExecuteGetModelStatus(), - "modelcontrol" => await ExecuteModelControlAsync(Deserialize(message)), - _ => throw new ArgumentOutOfRangeException(nameof(typeStr), typeStr, "Unknown config request type") - }; - } - catch (Exception ex) - { - logger.LogError(ex, "Error processing config request"); - response = new ConfigError(ex); - } - - await Clients.Caller.SendAsync("ReceiveResponse", response); - } - - private ConfigGetSuccess ExecuteGetConfig() - { - var config = ConfigHolder.Value; - - return new ConfigGetSuccess( - new ServerConfig(config.Server.Host, config.Server.Port, config.Server.Debug), - new DownloadConfig( - config.Download.MaxChunks, - config.Download.LimitBytesPerSecond, - config.Download.SaveFolder, - new RetryConfig(config.Download.Retry.Timeout, config.Download.Retry.MaxRetries)), - new JobConfig(config.Job.MaxConcurrentJobs), - new ImageConfig( - new FaceConfig( - config.Image.Face.Confidence, - config.Image.Face.UnionAll), - new SaliencyConfig( - config.Image.Saliency.PaddingTop, - config.Image.Saliency.PaddingBottom, - config.Image.Saliency.PaddingLeft, - config.Image.Saliency.PaddingRight)) - ); - } - - private ConfigUpdateSuccess ExecuteUpdateConfig(ConfigUpdate request) - { - if (HasWorkingJobs()) - throw new InvalidOperationException( - "Cannot update config while jobs are running. Pause or complete them first."); - - var config = new Config - { - Server = request.Server != null - ? new Config.ServerConfig - { - Host = request.Server.Host, - Debug = request.Server.Debug, - Port = request.Server.Port - } - : ConfigHolder.Value.Server, - Download = request.Download != null - ? new Config.DownloadConfig - { - MaxChunks = request.Download.MaxChunks, - LimitBytesPerSecond = request.Download.LimitBytesPerSecond, - SaveFolder = request.Download.SaveFolder, - Retry = new Config.DownloadConfig.RetryConfig - { - Timeout = request.Download.Retry.Timeout, - MaxRetries = request.Download.Retry.MaxRetries - } - } - : ConfigHolder.Value.Download, - Job = request.Job != null - ? new Config.JobConfig - { - MaxConcurrentJobs = request.Job.MaxConcurrentJobs - } - : ConfigHolder.Value.Job, - Image = request.Image != null - ? new Config.ImageConfig - { - Face = new Config.ImageConfig.FaceConfig - { - Confidence = request.Image.Face.Confidence, - UnionAll = request.Image.Face.UnionAll - }, - Saliency = new Config.ImageConfig.SaliencyConfig - { - PaddingTop = request.Image.Saliency.PaddingTop, - PaddingBottom = request.Image.Saliency.PaddingBottom, - PaddingLeft = request.Image.Saliency.PaddingLeft, - PaddingRight = request.Image.Saliency.PaddingRight - } - } - : ConfigHolder.Value.Image - }; - ConfigHolder.Value = config; - ConfigLoader.Save(ConfigHolder.Value, ConfigHolder.Locker); - - logger.LogInformation("Configuration updated by client {ConnectionId}", Context.ConnectionId); - return new ConfigUpdateSuccess(true, "Configuration updated successfully"); - } - - private ConfigReloadSuccess ExecuteReloadConfig() - { - if (HasWorkingJobs()) - throw new InvalidOperationException("Cannot reload config while jobs are running."); - - var loaded = ConfigLoader.Load(ConfigHolder.Locker); - if (loaded != null) - ConfigHolder.Value = loaded; - logger.LogInformation("Configuration reloaded by client {ConnectionId}", Context.ConnectionId); - - return new ConfigReloadSuccess(true, "Configuration reloaded successfully"); - } - - private ConfigResetSuccess ExecuteResetConfig() - { - if (HasWorkingJobs()) - throw new InvalidOperationException("Cannot reset config while jobs are running."); - - ConfigHolder.Reset(); - ConfigLoader.Save(ConfigHolder.Value, ConfigHolder.Locker); - logger.LogInformation("Configuration reset to defaults by client {ConnectionId}", Context.ConnectionId); - - return new ConfigResetSuccess(true, "Configuration reset to defaults"); - } - - private ModelStatusSuccess ExecuteGetModelStatus() - { - return new ModelStatusSuccess(imageService.IsFaceModelAvailable); - } - - private async Task ExecuteModelControlAsync(ModelControl request) - { - var model = request.Model.ToLowerInvariant(); - var action = request.Action.ToLowerInvariant(); - - if (model != "face") - throw new ArgumentException($"Unknown model: {request.Model}"); - - bool success; - string message; - - switch (action) - { - case "init": - if (HasWorkingJobs()) - throw new InvalidOperationException("Cannot initialize model while jobs are running."); - await imageService.InitFaceModelAsync(); - success = imageService.IsFaceModelAvailable; - message = success - ? "Face detection model initialized successfully" - : "Failed to initialize face detection model"; - logger.LogInformation("Face model init by client {ConnectionId}: {Success}", Context.ConnectionId, - success); - break; - - case "deinit": - if (HasWorkingJobs()) - throw new InvalidOperationException("Cannot deinitialize model while jobs are running."); - await imageService.DeInitFaceModelAsync(); - success = !imageService.IsFaceModelAvailable; - message = success - ? "Face detection model deinitialized successfully" - : "Failed to deinitialize face detection model"; - logger.LogInformation("Face model deinit by client {ConnectionId}: {Success}", Context.ConnectionId, - success); - break; - - default: - throw new ArgumentException($"Unknown action: {request.Action}"); - } - - return new ModelControlSuccess(request.Model, request.Action, success, message); - } - - private bool HasWorkingJobs() - { - return jobManager.Active.EnumerateGroups() - .Any(group => group.Status is GroupStatus.Pending or GroupStatus.Running); - } -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Presentation/Features/Jobs/JobHub.cs b/backend/src/SlideGenerator.Presentation/Features/Jobs/JobHub.cs deleted file mode 100644 index 1ff31fe5..00000000 --- a/backend/src/SlideGenerator.Presentation/Features/Jobs/JobHub.cs +++ /dev/null @@ -1,486 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Serialization; -using Microsoft.AspNetCore.SignalR; -using SlideGenerator.Application.Common.Base.DTOs.Responses; -using SlideGenerator.Application.Features.Jobs; -using SlideGenerator.Application.Features.Jobs.Contracts; -using SlideGenerator.Application.Features.Jobs.DTOs.Requests; -using SlideGenerator.Application.Features.Jobs.DTOs.Responses.Successes; -using SlideGenerator.Application.Features.Slides; -using SlideGenerator.Application.Features.Slides.DTOs.Components; -using SlideGenerator.Application.Features.Slides.DTOs.Enums; -using SlideGenerator.Application.Features.Slides.DTOs.Requests; -using SlideGenerator.Application.Features.Slides.DTOs.Responses.Errors; -using SlideGenerator.Application.Features.Slides.DTOs.Responses.Successes; -using SlideGenerator.Domain.Features.Jobs.Components; -using SlideGenerator.Domain.Features.Jobs.Enums; -using SlideGenerator.Domain.Features.Jobs.Interfaces; -using SlideGenerator.Domain.Features.Jobs.States; -using HubBase = SlideGenerator.Presentation.Common.Hubs.Hub; - -namespace SlideGenerator.Presentation.Features.Jobs; - -/// -/// SignalR hub for job creation, control, and query. -/// -public class JobHub( - IJobManager jobManager, - ISlideTemplateManager slideTemplateManager, - IJobStateStore jobStateStore, - ILogger logger) : HubBase -{ - private static readonly JsonSerializerOptions JobExportJsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - Converters = { new JsonStringEnumConverter() } - }; - - public Task SubscribeGroup(string groupJobId) - { - return Groups.AddToGroupAsync(Context.ConnectionId, JobSignalRGroups.GroupGroup(groupJobId)); - } - - public Task SubscribeSheet(string sheetJobId) - { - return Groups.AddToGroupAsync(Context.ConnectionId, JobSignalRGroups.SheetGroup(sheetJobId)); - } - - /// - public override async Task OnConnectedAsync() - { - logger.LogInformation("Client connected: {ConnectionId}", Context.ConnectionId); - await base.OnConnectedAsync(); - } - - /// - public override async Task OnDisconnectedAsync(Exception? exception) - { - logger.LogInformation("Client disconnected: {ConnectionId}", Context.ConnectionId); - await base.OnDisconnectedAsync(exception); - } - - public async Task ProcessRequest(JsonElement message) - { - Response response; - - try - { - var typeStr = message.GetProperty("type").GetString()?.ToLowerInvariant(); - - response = typeStr switch - { - "scanshapes" => ExecuteScanShapes( - Deserialize(message)), - "scanplaceholders" => ExecuteScanPlaceholders( - Deserialize(message)), - "scantemplate" => ExecuteScanTemplate( - Deserialize(message)), - "taskcreate" or "jobcreate" => ExecuteJobCreate( - Deserialize(message)), - "taskcontrol" or "jobcontrol" => ExecuteJobControl( - Deserialize(message)), - "taskquery" or "jobquery" => ExecuteJobQuery( - Deserialize(message)), - _ => throw new ArgumentOutOfRangeException(nameof(typeStr), typeStr, "Unknown request type") - }; - } - catch (Exception ex) - { - logger.LogError(ex, "Error processing presentation request"); - response = new Error(ex); - } - - await Clients.Caller.SendAsync("ReceiveResponse", response); - } - - private SlideScanShapesSuccess ExecuteScanShapes(SlideScanShapes request) - { - var added = slideTemplateManager.AddTemplate(request.FilePath); - try - { - var template = slideTemplateManager.GetTemplate(request.FilePath); - - var imageShapes = template.GetAllImageShapes(); - var shapes = template.GetAllShapes() - .Select(shape => - { - var data = imageShapes.TryGetValue(shape.Id, out var preview) - ? Convert.ToBase64String(preview.Image) - : string.Empty; - return new ShapeDto(shape.Id, shape.Name, data, shape.Kind, shape.IsImage); - }) - .ToArray(); - - return new SlideScanShapesSuccess(request.FilePath, shapes); - } - finally - { - if (added) slideTemplateManager.RemoveTemplate(request.FilePath); - } - } - - private SlideScanPlaceholdersSuccess ExecuteScanPlaceholders(SlideScanPlaceholders request) - { - var added = slideTemplateManager.AddTemplate(request.FilePath); - try - { - var template = slideTemplateManager.GetTemplate(request.FilePath); - - var placeholders = template.GetAllTextPlaceholders().ToArray(); - return new SlideScanPlaceholdersSuccess(request.FilePath, placeholders); - } - finally - { - if (added) slideTemplateManager.RemoveTemplate(request.FilePath); - } - } - - private SlideScanTemplateSuccess ExecuteScanTemplate(SlideScanTemplate request) - { - var added = slideTemplateManager.AddTemplate(request.FilePath); - try - { - var template = slideTemplateManager.GetTemplate(request.FilePath); - - var imageShapes = template.GetAllImageShapes(); - var shapes = template.GetAllShapes() - .Select(shape => - { - var data = imageShapes.TryGetValue(shape.Id, out var preview) - ? Convert.ToBase64String(preview.Image) - : string.Empty; - return new ShapeDto(shape.Id, shape.Name, data, shape.Kind, shape.IsImage); - }) - .ToArray(); - - var placeholders = template.GetAllTextPlaceholders().ToArray(); - return new SlideScanTemplateSuccess(request.FilePath, shapes, placeholders); - } - finally - { - if (added) slideTemplateManager.RemoveTemplate(request.FilePath); - } - } - - private JobCreateSuccess ExecuteJobCreate(JobCreate request) - { - if (string.IsNullOrWhiteSpace(request.TemplatePath)) - throw new InvalidOperationException("TemplatePath is required."); - if (string.IsNullOrWhiteSpace(request.SpreadsheetPath)) - throw new InvalidOperationException("SpreadsheetPath is required."); - if (string.IsNullOrWhiteSpace(request.OutputPath)) - throw new InvalidOperationException("OutputPath is required."); - - if (request.JobType == JobType.Sheet && string.IsNullOrWhiteSpace(request.SheetName)) - throw new InvalidOperationException("SheetName is required for sheet jobs."); - - logger.LogInformation( - "Creating job: Type={JobType}, Template={TemplatePath}, Spreadsheet={SpreadsheetPath}, AutoStart={AutoStart}", - request.JobType, request.TemplatePath, request.SpreadsheetPath, request.AutoStart); - - var group = jobManager.Active.CreateGroup(request); - if (request.AutoStart) - jobManager.Active.StartGroup(group.Id); - - logger.LogInformation("Job group created: {GroupId} with {SheetCount} sheets", - group.Id, group.Sheets.Count); - - if (request.JobType == JobType.Sheet) - { - var sheet = group.Sheets.Values.First(s => - string.Equals(s.SheetName, request.SheetName, StringComparison.OrdinalIgnoreCase)); - - return new JobCreateSuccess(BuildJobSummary(sheet), null); - } - - var jobIds = new Dictionary(group.Sheets.Count); - foreach (var kv in group.Sheets) - jobIds[kv.Value.SheetName] = kv.Key; - - return new JobCreateSuccess(BuildJobSummary(group), jobIds); - } - - private JobQuerySuccess ExecuteJobQuery(JobQuery request) - { - if (!string.IsNullOrWhiteSpace(request.JobId)) - { - var (jobType, group, sheet) = ResolveJob(request.JobId, request.JobType); - var payload = request.IncludePayload - ? jobType == JobType.Group - ? GetGroupPayload(request.JobId) - : GetSheetPayload(request.JobId) - : null; - - var detail = jobType == JobType.Group - ? BuildJobDetail(group!, request.IncludeSheets, payload) - : BuildJobDetail(sheet!, payload); - - return new JobQuerySuccess(detail, null); - } - - var includeGroups = request.JobType != JobType.Sheet; - var includeSheets = request.JobType != JobType.Group; - - var jobs = new List(); - if (request.Scope is JobQueryScope.Active or JobQueryScope.All) - { - if (includeGroups) - jobs.AddRange(jobManager.Active.EnumerateGroups().Select(BuildJobSummary)); - if (includeSheets) - jobs.AddRange(jobManager.Active.EnumerateSheets().Select(BuildJobSummary)); - } - - if (request.Scope is JobQueryScope.Completed or JobQueryScope.All) - { - if (includeGroups) - jobs.AddRange(jobManager.Completed.EnumerateGroups().Select(BuildJobSummary)); - if (includeSheets) - jobs.AddRange(jobManager.Completed.EnumerateSheets().Select(BuildJobSummary)); - } - - return new JobQuerySuccess(null, jobs); - } - - private JobControlSuccess ExecuteJobControl(JobControl request) - { - var (jobType, group, sheet) = ResolveJob(request.JobId, request.JobType); - var action = request.Action == ControlAction.Stop ? ControlAction.Cancel : request.Action; - - logger.LogInformation("Job control: {Action} on {JobType} {JobId}", - action, jobType, request.JobId); - - switch (jobType) - { - case JobType.Group: - switch (action) - { - case ControlAction.Pause: - jobManager.Active.PauseGroup(group!.Id); - break; - case ControlAction.Resume: - jobManager.Active.ResumeGroup(group!.Id); - break; - case ControlAction.Cancel: - jobManager.Active.CancelGroup(group!.Id); - break; - case ControlAction.Remove: - if (jobManager.Active.ContainsGroup(group!.Id)) - jobManager.Active.CancelAndRemoveGroup(group.Id); - else - jobManager.Completed.RemoveGroup(group.Id); - break; - } - - break; - case JobType.Sheet: - switch (action) - { - case ControlAction.Pause: - jobManager.Active.PauseSheet(sheet!.Id); - break; - case ControlAction.Resume: - jobManager.Active.ResumeSheet(sheet!.Id); - break; - case ControlAction.Cancel: - jobManager.Active.CancelSheet(sheet!.Id); - break; - case ControlAction.Remove: - if (jobManager.Active.ContainsSheet(sheet!.Id)) - jobManager.Active.CancelAndRemoveSheet(sheet.Id); - else - jobManager.Completed.RemoveSheet(sheet.Id); - break; - } - - break; - } - - return new JobControlSuccess(request.JobId, jobType, action); - } - - private static JobSummary BuildJobSummary(IJobGroup group) - { - return new JobSummary( - group.Id, - JobType.Group, - group.Status.ToJobState(), - group.Progress, - null, - null, - group.OutputFolder.FullName, - group.ErrorCount, - null); - } - - private static JobSummary BuildJobSummary(IJobSheet sheet) - { - return new JobSummary( - sheet.Id, - JobType.Sheet, - sheet.Status.ToJobState(), - sheet.Progress, - sheet.GroupId, - sheet.SheetName, - sheet.OutputPath, - sheet.ErrorCount, - sheet.HangfireJobId); - } - - private static JobDetail BuildJobDetail(IJobGroup group, bool includeSheets, string? payloadJson) - { - IReadOnlyDictionary? sheets = null; - if (includeSheets) - sheets = group.Sheets.ToDictionary( - kv => kv.Key, - kv => BuildJobSummary(kv.Value)); - - return new JobDetail( - group.Id, - JobType.Group, - group.Status.ToJobState(), - group.Progress, - group.ErrorCount, - null, - null, - null, - null, - null, - null, - group.OutputFolder.FullName, - sheets, - payloadJson, - null); - } - - private static JobDetail BuildJobDetail(IJobSheet sheet, string? payloadJson) - { - return new JobDetail( - sheet.Id, - JobType.Sheet, - sheet.Status.ToJobState(), - sheet.Progress, - sheet.ErrorCount, - sheet.ErrorMessage, - sheet.GroupId, - sheet.SheetName, - sheet.CurrentRow, - sheet.TotalRows, - sheet.OutputPath, - null, - null, - payloadJson, - sheet.HangfireJobId); - } - - private (JobType JobType, IJobGroup? Group, IJobSheet? Sheet) ResolveJob(string jobId, JobType? jobType) - { - if (jobType == JobType.Group) - { - var group = jobManager.GetGroup(jobId) - ?? throw new InvalidOperationException($"Group job {jobId} not found"); - return (JobType.Group, group, null); - } - - if (jobType == JobType.Sheet) - { - var sheet = jobManager.GetSheet(jobId) - ?? throw new InvalidOperationException($"Sheet job {jobId} not found"); - return (JobType.Sheet, null, sheet); - } - - var resolvedGroup = jobManager.GetGroup(jobId); - if (resolvedGroup != null) - return (JobType.Group, resolvedGroup, null); - - var resolvedSheet = jobManager.GetSheet(jobId); - if (resolvedSheet != null) - return (JobType.Sheet, null, resolvedSheet); - - throw new InvalidOperationException($"Job {jobId} not found"); - } - - private string? GetGroupPayload(string groupId) - { - var groupState = jobStateStore.GetGroupAsync(groupId, CancellationToken.None) - .GetAwaiter().GetResult(); - if (groupState == null) - return null; - - var sheets = jobStateStore.GetSheetsByGroupAsync(groupId, CancellationToken.None) - .GetAwaiter().GetResult(); - return BuildGroupPayload(groupState, sheets); - } - - private string? GetSheetPayload(string sheetId) - { - var sheetState = jobStateStore.GetSheetAsync(sheetId, CancellationToken.None) - .GetAwaiter().GetResult(); - if (sheetState == null) - return null; - - var groupState = jobStateStore.GetGroupAsync(sheetState.GroupId, CancellationToken.None) - .GetAwaiter().GetResult(); - return BuildSheetPayload(sheetState, groupState); - } - - private static string BuildGroupPayload( - GroupJobState groupState, - IReadOnlyList sheetStates) - { - var sheetNames = sheetStates.Select(s => s.SheetName).Distinct().ToArray(); - var firstSheet = sheetStates.FirstOrDefault(); - var textConfigs = firstSheet != null ? MapTextConfigs(firstSheet.TextConfigs) : null; - var imageConfigs = firstSheet != null ? MapImageConfigs(firstSheet.ImageConfigs) : null; - var payload = new JobExportPayload( - JobType.Group, - groupState.TemplatePath, - groupState.WorkbookPath, - groupState.OutputFolderPath, - sheetNames, - null, - textConfigs, - imageConfigs); - - return JsonSerializer.Serialize(payload, JobExportJsonOptions); - } - - private static string BuildSheetPayload( - SheetJobState sheetState, - GroupJobState? groupState) - { - var payload = new JobExportPayload( - JobType.Sheet, - groupState?.TemplatePath ?? string.Empty, - groupState?.WorkbookPath ?? string.Empty, - sheetState.OutputPath, - null, - sheetState.SheetName, - MapTextConfigs(sheetState.TextConfigs), - MapImageConfigs(sheetState.ImageConfigs)); - - return JsonSerializer.Serialize(payload, JobExportJsonOptions); - } - - private static SlideTextConfig[]? MapTextConfigs(JobTextConfig[] configs) - { - if (configs.Length == 0) return null; - return [.. configs.Select(c => new SlideTextConfig(c.Pattern, c.Columns))]; - } - - private static SlideImageConfig[]? MapImageConfigs(JobImageConfig[] configs) - { - if (configs.Length == 0) return null; - return [.. configs.Select(c => new SlideImageConfig(c.ShapeId, c.Columns, c.RoiType, c.CropType))]; - } - - private sealed record JobExportPayload( - JobType JobType, - string TemplatePath, - string SpreadsheetPath, - string OutputPath, - string[]? SheetNames, - string? SheetName, - SlideTextConfig[]? TextConfigs, - SlideImageConfig[]? ImageConfigs); -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Presentation/Features/Sheets/SheetHub.cs b/backend/src/SlideGenerator.Presentation/Features/Sheets/SheetHub.cs deleted file mode 100644 index 126a4920..00000000 --- a/backend/src/SlideGenerator.Presentation/Features/Sheets/SheetHub.cs +++ /dev/null @@ -1,165 +0,0 @@ -using System.Collections.Concurrent; -using System.Text.Json; -using Microsoft.AspNetCore.SignalR; -using SlideGenerator.Application.Common.Base.DTOs.Responses; -using SlideGenerator.Application.Features.Sheets; -using SlideGenerator.Application.Features.Sheets.DTOs.Components; -using SlideGenerator.Application.Features.Sheets.DTOs.Requests.Workbook; -using SlideGenerator.Application.Features.Sheets.DTOs.Requests.Worksheet; -using SlideGenerator.Application.Features.Sheets.DTOs.Responses.Errors; -using SlideGenerator.Application.Features.Sheets.DTOs.Responses.Successes.Workbook; -using SlideGenerator.Application.Features.Sheets.DTOs.Responses.Successes.Worksheet; -using SlideGenerator.Domain.Features.Sheets.Interfaces; -using SlideGenerator.Presentation.Common.Exceptions.Hubs; -using HubBase = SlideGenerator.Presentation.Common.Hubs.Hub; - -namespace SlideGenerator.Presentation.Features.Sheets; - -/// -/// SignalR Hub for spreadsheet operations. -/// -public class SheetHub(ISheetService sheetService, ILogger logger) : HubBase -{ - private static readonly ConcurrentDictionary> - WorkbooksOfConnections = new(); - - private ConcurrentDictionary Workbooks - => WorkbooksOfConnections.GetValueOrDefault(Context.ConnectionId) - ?? throw new ConnectionNotFound(Context.ConnectionId); - - /// - public override async Task OnConnectedAsync() - { - logger.LogInformation("Client connected: {ConnectionId}", Context.ConnectionId); - WorkbooksOfConnections[Context.ConnectionId] = new ConcurrentDictionary(); - await base.OnConnectedAsync(); - } - - /// - public override async Task OnDisconnectedAsync(Exception? exception) - { - logger.LogInformation("Client disconnected: {ConnectionId}", Context.ConnectionId); - - // Cleanup open workbooks for this connection - if (WorkbooksOfConnections.TryRemove(Context.ConnectionId, out var workbooks)) - foreach (var key in workbooks.Keys) - if (workbooks.TryRemove(key, out var wb)) - wb.Dispose(); - - await base.OnDisconnectedAsync(exception); - } - - /// - /// Processes a sheet request based on type. - /// - public async Task ProcessRequest(JsonElement message) - { - Response response; - var filePath = string.Empty; - - try - { - var typeStr = message.GetProperty("type").GetString()?.ToLowerInvariant(); - filePath = message.GetProperty("filePath").GetString() ?? string.Empty; - - response = typeStr switch - { - "openfile" => ExecuteOpenFile( - Deserialize(message)), - "closefile" => ExecuteCloseFile( - Deserialize(message)), - "gettables" => ExecuteGetSheets( - Deserialize(message)), - "getheaders" => ExecuteGetHeaders( - Deserialize(message)), - "getrow" => ExecuteGetRow( - Deserialize(message)), - "getworkbookinfo" => ExecuteGetWorkbookInfo( - Deserialize(message)), - _ => throw new ArgumentOutOfRangeException(nameof(typeStr), typeStr, null) - }; - } - catch (Exception ex) - { - logger.LogError(ex, "Error processing sheet request"); - response = new SheetError(filePath, ex); - } - - await Clients.Caller.SendAsync("ReceiveResponse", response); - } - - private OpenBookSheetSuccess ExecuteOpenFile(SheetWorkbookOpen request) - { - GetOrOpenWorkbook(request.FilePath); - return new OpenBookSheetSuccess(request.FilePath); - } - - private SheetWorkbookCloseSuccess ExecuteCloseFile(SheetWorkbookClose request) - { - if (Workbooks.TryRemove(request.FilePath, out var wb)) - wb.Dispose(); - - return new SheetWorkbookCloseSuccess(request.FilePath); - } - - private SheetWorkbookGetSheetInfoSuccess ExecuteGetSheets(SheetWorkbookGetSheetInfo request) - { - var workbook = GetOrOpenWorkbook(request.FilePath); - - return new SheetWorkbookGetSheetInfoSuccess - ( - request.FilePath, - sheetService.GetSheetsInfo(workbook) - ); - } - - private SheetWorksheetGetHeadersSuccess ExecuteGetHeaders(SheetWorksheetGetHeaders request) - { - var workbook = GetOrOpenWorkbook(request.FilePath); - - return new SheetWorksheetGetHeadersSuccess - ( - request.FilePath, - request.SheetName, - sheetService.GetHeaders(workbook, request.SheetName) - ); - } - - private SheetWorksheetGetRowSuccess ExecuteGetRow(SheetWorksheetGetRow request) - { - var workbook = GetOrOpenWorkbook(request.FilePath); - - return new SheetWorksheetGetRowSuccess - ( - request.FilePath, - request.TableName, - request.RowNumber, - sheetService.GetRow(workbook, request.TableName, request.RowNumber) - ); - } - - private SheetWorkbookGetInfoSuccess ExecuteGetWorkbookInfo(GetWorkbookInfoRequest request) - { - var workbook = GetOrOpenWorkbook(request.FilePath); - var sheetsInfo = sheetService.GetSheetsInfo(workbook); - - var sheets = new List(); - foreach (var (sheetName, rowCount) in sheetsInfo) - { - var headers = sheetService.GetHeaders(workbook, sheetName); - sheets.Add(new SheetWorksheetInfo(sheetName, headers, rowCount)); - } - - return new SheetWorkbookGetInfoSuccess(request.FilePath, workbook.Name, sheets); - } - - private ISheetBook GetOrOpenWorkbook(string sheetPath) - { - if (Workbooks.TryGetValue(sheetPath, out var workbook)) - return workbook; - workbook = sheetService.OpenFile(sheetPath); - Workbooks[sheetPath] = workbook; - - return workbook; - } -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Presentation/Program.cs b/backend/src/SlideGenerator.Presentation/Program.cs deleted file mode 100644 index da0b3979..00000000 --- a/backend/src/SlideGenerator.Presentation/Program.cs +++ /dev/null @@ -1,168 +0,0 @@ -using System.Text.Json; -using Hangfire; -using Hangfire.Storage.SQLite; -using SlideGenerator.Application.Features.Configs; -using SlideGenerator.Application.Features.Downloads; -using SlideGenerator.Application.Features.Images; -using SlideGenerator.Application.Features.Jobs.Contracts; -using SlideGenerator.Application.Features.Sheets; -using SlideGenerator.Application.Features.Slides; -using SlideGenerator.Domain.Configs; -using SlideGenerator.Domain.Features.Downloads; -using SlideGenerator.Domain.Features.IO; -using SlideGenerator.Domain.Features.Jobs.Interfaces; -using SlideGenerator.Infrastructure.Common.Logging; -using SlideGenerator.Infrastructure.Features.Configs; -using SlideGenerator.Infrastructure.Features.Downloads.Services; -using SlideGenerator.Infrastructure.Features.Images.Services; -using SlideGenerator.Infrastructure.Features.IO; -using SlideGenerator.Infrastructure.Features.Jobs.Hangfire; -using SlideGenerator.Infrastructure.Features.Jobs.Services; -using SlideGenerator.Infrastructure.Features.Sheets.Services; -using SlideGenerator.Infrastructure.Features.Slides.Services; -using SlideGenerator.Presentation.Features.Configs; -using SlideGenerator.Presentation.Features.Jobs; -using SlideGenerator.Presentation.Features.Sheets; - -namespace SlideGenerator.Presentation; - -/// -/// The main class of SlideGenerator Presentation layer. -/// -public static class Program -{ - private static void LoadConfig() - { - var loaded = ConfigLoader.Load(ConfigHolder.Locker); - if (loaded != null) - ConfigHolder.Value = loaded; - else ConfigLoader.Save(ConfigHolder.Value, ConfigHolder.Locker); - } - - private static WebApplicationBuilder InitializeBuilder(string[] args) - { - var builder = WebApplication.CreateBuilder(args); - - // Configure Serilog from Infrastructure - builder.AddInfrastructureLogging(); - - builder.Services.AddSignalR(options => options.EnableDetailedErrors = ConfigHolder.Value.Server.Debug) - .AddJsonProtocol(options => - { - options.PayloadSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; - options.PayloadSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.CamelCase; - }); - builder.Services.AddHttpClient(); - builder.Services.AddLogging(); - - // Application Services - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(sp => (IDownloadClient)sp.GetRequiredService()); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(sp => sp.GetRequiredService()); - builder.Services.AddSingleton(); - - // Job Services - builder.Services.AddSingleton(); - builder.Services.AddSingleton(sp => sp.GetRequiredService()); - builder.Services.AddSingleton>(); - builder.Services.AddScoped(); - builder.Services.AddSingleton(); - builder.Services.AddHostedService(); - - // Hangfire Setup - var dbPath = Config.DefaultDatabasePath; - builder.Services.AddHangfire(configuration => configuration - .SetDataCompatibilityLevel(CompatibilityLevel.Version_180) - .UseSimpleAssemblyNameTypeSerializer() - .UseRecommendedSerializerSettings() - .UseSQLiteStorage(dbPath)); - builder.Services.AddHangfireServer(options => - { - options.WorkerCount = ConfigHolder.Value.Job.MaxConcurrentJobs; - }); - - builder.Services.AddCors(options => - { - options.AddDefaultPolicy(policy => - { - policy.AllowAnyHeader() - .AllowAnyMethod() - .AllowAnyOrigin(); - }); - }); - - return builder; - } - - private static WebApplication InitializeApp(WebApplicationBuilder builder) - { - var app = builder.Build(); - app.UseCors(); - app.UseWebSockets(); - - app.MapHub("/hubs/sheet"); - app.MapHub("/hubs/job"); - app.MapHub("/hubs/task"); - app.MapHub("/hubs/config"); - - app.MapGet("/", () => new - { - Name = Config.AppName, - Description = Config.AppDescription, - Repository = Config.AppUrl - }); - app.MapGet("/health", () => Results.Ok(new { IsRunning = true })); - app.UseHangfireDashboard("/dashboard", new DashboardOptions - { - DashboardTitle = Config.AppName, - Authorization = [], - IsReadOnlyFunc = _ => true, - DisplayNameFunc = (_, job) => - { - if (job.Args is { Count: > 0 } && job.Args[0] is string sheetId) - { - var displayName = SheetJobNameRegistry.GetDisplayName(sheetId); - if (!string.IsNullOrEmpty(displayName)) - return displayName; - } - - return $"{job.Type.Name}.{job.Method.Name}"; - } - }); - - // Get host/port - var host = ConfigHolder.Value.Server.Host; - app.Urls.Clear(); - app.Urls.Add($"http://{host}:{ConfigHolder.Value.Server.Port}"); - - // On Application Stopping - app.Lifetime.ApplicationStopping.Register(() => - { - ConfigLoader.Save(ConfigHolder.Value, ConfigHolder.Locker); - }); - - return app; - } - - private static async Task Main(string[] args) - { - LoadConfig(); - - try - { - var builder = InitializeBuilder(args); - var app = InitializeApp(builder); - await app.RunAsync(); - } - finally - { - ConfigLoader.Save(ConfigHolder.Value, ConfigHolder.Locker); - await LoggingExtensions.CloseAndFlushAsync(); - } - } -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Presentation/Properties/launchSettings.json b/backend/src/SlideGenerator.Presentation/Properties/launchSettings.json deleted file mode 100644 index 61b6db9a..00000000 --- a/backend/src/SlideGenerator.Presentation/Properties/launchSettings.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "profiles": { - "http": { - "commandName": "Project", - "workingDirectory": "$(SolutionDir)", - "launchBrowser": true, - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - }, - "dotnetRunMessages": true, - "applicationUrl": "http://localhost:5262" - }, - "https": { - "commandName": "Project", - "workingDirectory": "$(SolutionDir)", - "launchBrowser": true, - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - }, - "dotnetRunMessages": true, - "applicationUrl": "https://localhost:7227;http://localhost:5262" - }, - "Container (Dockerfile)": { - "commandName": "Docker", - "launchBrowser": true, - "launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}", - "environmentVariables": { - "ASPNETCORE_HTTPS_PORTS": "8081", - "ASPNETCORE_HTTP_PORTS": "8080" - }, - "publishAllPorts": true, - "useSSL": true, - "containerName": "SlideGeneratorBackend" - } - }, - "$schema": "https://json.schemastore.org/launchsettings.json" -} \ No newline at end of file diff --git a/backend/src/SlideGenerator.Presentation/SlideGenerator.Presentation.csproj b/backend/src/SlideGenerator.Presentation/SlideGenerator.Presentation.csproj deleted file mode 100644 index 0d9a49b7..00000000 --- a/backend/src/SlideGenerator.Presentation/SlideGenerator.Presentation.csproj +++ /dev/null @@ -1,21 +0,0 @@ - - - - net10.0 - enable - enable - 22b3b648-7b0b-493b-a09d-be022aaf21bd - Linux - ..\.. - true - GPL-3.0-only - $(NoWarn);1591 - - - - - - - - - \ No newline at end of file diff --git a/backend/src/SlideGenerator.Presentation/appsettings.Development.json b/backend/src/SlideGenerator.Presentation/appsettings.Development.json deleted file mode 100644 index 0c208ae9..00000000 --- a/backend/src/SlideGenerator.Presentation/appsettings.Development.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - } -} diff --git a/backend/src/SlideGenerator.Presentation/appsettings.json b/backend/src/SlideGenerator.Presentation/appsettings.json deleted file mode 100644 index 10f68b8c..00000000 --- a/backend/src/SlideGenerator.Presentation/appsettings.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "AllowedHosts": "*" -} 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/.gitignore b/frontend/.gitignore deleted file mode 100644 index 8fa845e4..00000000 --- a/frontend/.gitignore +++ /dev/null @@ -1,166 +0,0 @@ -# Created by https://www.toptal.com/developers/gitignore/api/node,vue,react -# Edit at https://www.toptal.com/developers/gitignore?templates=node,vue,react - -### Node ### -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -lerna-debug.log* -.pnpm-debug.log* - -# Diagnostic reports (https://nodejs.org/api/report.html) -report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json - -# Runtime data -pids -*.pid -*.seed -*.pid.lock - -# Directory for instrumented libs generated by jscoverage/JSCover -lib-cov - -# Coverage directory used by tools like istanbul -coverage -*.lcov - -# nyc test coverage -.nyc_output - -# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) -.grunt - -# Bower dependency directory (https://bower.io/) -bower_components - -# node-waf configuration -.lock-wscript - -# Compiled binary addons (https://nodejs.org/api/addons.html) -build/Release - -# Dependency directories -node_modules/ -jspm_packages/ - -# Snowpack dependency directory (https://snowpack.dev/) -web_modules/ - -# TypeScript cache -*.tsbuildinfo - -# Optional npm cache directory -.npm - -# Optional eslint cache -.eslintcache - -# Optional stylelint cache -.stylelintcache - -# Microbundle cache -.rpt2_cache/ -.rts2_cache_cjs/ -.rts2_cache_es/ -.rts2_cache_umd/ - -# Optional REPL history -.node_repl_history - -# Output of 'npm pack' -*.tgz - -# Yarn Integrity file -.yarn-integrity - -# dotenv environment variable files -.env -.env.development.local -.env.test.local -.env.production.local -.env.local - -# parcel-bundler cache (https://parceljs.org/) -.cache -.parcel-cache - -# Next.js build output -.next -out - -# Nuxt.js build / generate output -.nuxt -dist - -# Gatsby files -.cache/ -# Comment in the public line in if your project uses Gatsby and not Next.js -# https://nextjs.org/blog/next-9-1#public-directory-support -# public - -# vuepress build output -.vuepress/dist - -# vuepress v2.x temp and cache directory -.temp - -# Docusaurus cache and generated files -.docusaurus - -# Serverless directories -.serverless/ - -# FuseBox cache -.fusebox/ - -# DynamoDB Local files -.dynamodb/ - -# TernJS port file -.tern-port - -# Stores VSCode versions used for testing VSCode extensions -.vscode-test - -# yarn v2 -.yarn/cache -.yarn/unplugged -.yarn/build-state.yml -.yarn/install-state.gz -.pnp.* - -### Node Patch ### -# Serverless Webpack directories -.webpack/ - -# Optional stylelint cache - -# SvelteKit build / generate output -.svelte-kit - -### react ### -.DS_* -**/*.backup.* -**/*.back.* - -node_modules - -*.sublime* - -psd -thumb -sketch - -### Vue ### -# gitignore template for Vue.js projects -# -# Recommended template: Node.gitignore - -# End of https://www.toptal.com/developers/gitignore/api/node,vue,react - -/dist-electron/ -/backend/ -dist-types/ diff --git a/frontend/.prettierignore b/frontend/.prettierignore deleted file mode 100644 index de9ba303..00000000 --- a/frontend/.prettierignore +++ /dev/null @@ -1,8 +0,0 @@ -node_modules -dist -dist-electron -release -backend -coverage -*.local -package-lock.json diff --git a/frontend/.prettierrc b/frontend/.prettierrc deleted file mode 100644 index 18da32ff..00000000 --- a/frontend/.prettierrc +++ /dev/null @@ -1,10 +0,0 @@ -{ - "semi": true, - "singleQuote": true, - "tabWidth": 2, - "useTabs": true, - "trailingComma": "all", - "printWidth": 100, - "endOfLine": "auto", - "bracketSpacing": true -} diff --git a/frontend/README.md b/frontend/README.md deleted file mode 100644 index 4c94e730..00000000 --- a/frontend/README.md +++ /dev/null @@ -1,59 +0,0 @@ -# SlideGenerator Frontend - -The modern desktop interface for SlideGenerator, built with **Electron**, **React**, and **TypeScript**. It provides a user-friendly way to configure templates, manage datasets, and monitor generation progress in real-time. - -## Tech Stack - -- **Core:** [Electron](https://www.electronjs.org/) + [React 18](https://react.dev/) -- **Language:** TypeScript 5.0+ -- **Build Tool:** Vite -- **Styling:** CSS Modules / Global SCSS -- **State Management:** React Context API -- **Testing:** Vitest + React Testing Library - -## Quick Start - -### Prerequisites -- Node.js (LTS) -- npm - -### Installation - -```bash -cd frontend -npm install -``` - -### Development - -Start the app in development mode (with Hot Module Replacement): - -```bash -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`. - -## Project Structure - -The codebase is organized by feature: - -- **`src/app`**: Application shell, routing, and global providers. -- **`src/features`**: Self-contained feature modules. - - `create-task`: Wizard for creating new generation jobs. - - `process`: Real-time monitoring dashboard. - - `results`: History and file management. - - `settings`: Application configuration. -- **`src/shared`**: Reusable components, hooks, and services. -- **`electron`**: Main process code and preload scripts. - -## Documentation - -- **[Overview & Architecture](docs/en/overview.md)**: Deep dive into the frontend architecture. -- **[Development Guide](docs/en/development.md)**: Coding standards, testing, and adding features. -- **[Build & Packaging](docs/en/build-and-packaging.md)**: How to build and release the application. -- **[Usage Guide](docs/en/usage.md)**: User manual. - ---- - -[🇻🇳 Vietnamese Documentation](docs/vi) diff --git a/frontend/assets/fonts/IBMPlexMono-400.ttf b/frontend/assets/fonts/IBMPlexMono-400.ttf deleted file mode 100644 index 02d682ca..00000000 Binary files a/frontend/assets/fonts/IBMPlexMono-400.ttf and /dev/null differ diff --git a/frontend/assets/fonts/IBMPlexMono-500.ttf b/frontend/assets/fonts/IBMPlexMono-500.ttf deleted file mode 100644 index 54927d50..00000000 Binary files a/frontend/assets/fonts/IBMPlexMono-500.ttf and /dev/null differ diff --git a/frontend/assets/fonts/SpaceGrotesk-400.ttf b/frontend/assets/fonts/SpaceGrotesk-400.ttf deleted file mode 100644 index 576f9b55..00000000 Binary files a/frontend/assets/fonts/SpaceGrotesk-400.ttf and /dev/null differ diff --git a/frontend/assets/fonts/SpaceGrotesk-500.ttf b/frontend/assets/fonts/SpaceGrotesk-500.ttf deleted file mode 100644 index 6141a584..00000000 Binary files a/frontend/assets/fonts/SpaceGrotesk-500.ttf and /dev/null differ diff --git a/frontend/assets/fonts/SpaceGrotesk-600.ttf b/frontend/assets/fonts/SpaceGrotesk-600.ttf deleted file mode 100644 index 98cdfc62..00000000 Binary files a/frontend/assets/fonts/SpaceGrotesk-600.ttf and /dev/null differ diff --git a/frontend/assets/fonts/SpaceGrotesk-700.ttf b/frontend/assets/fonts/SpaceGrotesk-700.ttf deleted file mode 100644 index f4f80025..00000000 Binary files a/frontend/assets/fonts/SpaceGrotesk-700.ttf and /dev/null differ diff --git a/frontend/assets/images/about-selected.png b/frontend/assets/images/about-selected.png deleted file mode 100644 index 38081ae9..00000000 Binary files a/frontend/assets/images/about-selected.png and /dev/null differ diff --git a/frontend/assets/images/about.png b/frontend/assets/images/about.png deleted file mode 100644 index 66920653..00000000 Binary files a/frontend/assets/images/about.png and /dev/null differ diff --git a/frontend/assets/images/app-icon.ico b/frontend/assets/images/app-icon.ico deleted file mode 100644 index d65993eb..00000000 Binary files a/frontend/assets/images/app-icon.ico and /dev/null differ diff --git a/frontend/assets/images/app-icon.png b/frontend/assets/images/app-icon.png deleted file mode 100644 index 9a788d68..00000000 Binary files a/frontend/assets/images/app-icon.png and /dev/null differ diff --git a/frontend/assets/images/app-logo.png b/frontend/assets/images/app-logo.png deleted file mode 100644 index acf56e0c..00000000 Binary files a/frontend/assets/images/app-logo.png and /dev/null differ diff --git a/frontend/assets/images/chevron-down.png b/frontend/assets/images/chevron-down.png deleted file mode 100644 index 86e0b7dd..00000000 Binary files a/frontend/assets/images/chevron-down.png and /dev/null differ diff --git a/frontend/assets/images/clipboard.png b/frontend/assets/images/clipboard.png deleted file mode 100644 index 3634bbe5..00000000 Binary files a/frontend/assets/images/clipboard.png and /dev/null differ diff --git a/frontend/assets/images/close.png b/frontend/assets/images/close.png deleted file mode 100644 index 373455dd..00000000 Binary files a/frontend/assets/images/close.png and /dev/null differ diff --git a/frontend/assets/images/createTask-selected.png b/frontend/assets/images/createTask-selected.png deleted file mode 100644 index cf835697..00000000 Binary files a/frontend/assets/images/createTask-selected.png and /dev/null differ diff --git a/frontend/assets/images/createTask.png b/frontend/assets/images/createTask.png deleted file mode 100644 index cdce7de9..00000000 Binary files a/frontend/assets/images/createTask.png and /dev/null differ diff --git a/frontend/assets/images/download.png b/frontend/assets/images/download.png deleted file mode 100644 index a1d45568..00000000 Binary files a/frontend/assets/images/download.png and /dev/null differ diff --git a/frontend/assets/images/evernight-dance.gif b/frontend/assets/images/evernight-dance.gif deleted file mode 100644 index 4e48c2f6..00000000 Binary files a/frontend/assets/images/evernight-dance.gif and /dev/null differ diff --git a/frontend/assets/images/export-settings.png b/frontend/assets/images/export-settings.png deleted file mode 100644 index 5f80d3b6..00000000 Binary files a/frontend/assets/images/export-settings.png and /dev/null differ diff --git a/frontend/assets/images/folder.png b/frontend/assets/images/folder.png deleted file mode 100644 index bf492e1b..00000000 Binary files a/frontend/assets/images/folder.png and /dev/null differ diff --git a/frontend/assets/images/github-logo.png b/frontend/assets/images/github-logo.png deleted file mode 100644 index 352ea517..00000000 Binary files a/frontend/assets/images/github-logo.png and /dev/null differ diff --git a/frontend/assets/images/log.png b/frontend/assets/images/log.png deleted file mode 100644 index 8be4e8d1..00000000 Binary files a/frontend/assets/images/log.png and /dev/null differ diff --git a/frontend/assets/images/march-7th-dance.gif b/frontend/assets/images/march-7th-dance.gif deleted file mode 100644 index 8842a4cb..00000000 Binary files a/frontend/assets/images/march-7th-dance.gif and /dev/null differ diff --git a/frontend/assets/images/open.png b/frontend/assets/images/open.png deleted file mode 100644 index 2de67848..00000000 Binary files a/frontend/assets/images/open.png and /dev/null differ diff --git a/frontend/assets/images/pause.png b/frontend/assets/images/pause.png deleted file mode 100644 index ca540e73..00000000 Binary files a/frontend/assets/images/pause.png and /dev/null differ diff --git a/frontend/assets/images/process-selected.png b/frontend/assets/images/process-selected.png deleted file mode 100644 index 01000952..00000000 Binary files a/frontend/assets/images/process-selected.png and /dev/null differ diff --git a/frontend/assets/images/process.png b/frontend/assets/images/process.png deleted file mode 100644 index 34954957..00000000 Binary files a/frontend/assets/images/process.png and /dev/null differ diff --git a/frontend/assets/images/readme.png b/frontend/assets/images/readme.png deleted file mode 100644 index d1b6cf67..00000000 Binary files a/frontend/assets/images/readme.png and /dev/null differ diff --git a/frontend/assets/images/remove.png b/frontend/assets/images/remove.png deleted file mode 100644 index 04c844f5..00000000 Binary files a/frontend/assets/images/remove.png and /dev/null differ diff --git a/frontend/assets/images/result-selected.png b/frontend/assets/images/result-selected.png deleted file mode 100644 index 17036009..00000000 Binary files a/frontend/assets/images/result-selected.png and /dev/null differ diff --git a/frontend/assets/images/result.png b/frontend/assets/images/result.png deleted file mode 100644 index cc027357..00000000 Binary files a/frontend/assets/images/result.png and /dev/null differ diff --git a/frontend/assets/images/resume.png b/frontend/assets/images/resume.png deleted file mode 100644 index d25ac71f..00000000 Binary files a/frontend/assets/images/resume.png and /dev/null differ diff --git a/frontend/assets/images/setting-selected.png b/frontend/assets/images/setting-selected.png deleted file mode 100644 index 8e9fcc6b..00000000 Binary files a/frontend/assets/images/setting-selected.png and /dev/null differ diff --git a/frontend/assets/images/setting.png b/frontend/assets/images/setting.png deleted file mode 100644 index b76e16f6..00000000 Binary files a/frontend/assets/images/setting.png and /dev/null differ diff --git a/frontend/assets/images/stop.png b/frontend/assets/images/stop.png deleted file mode 100644 index b7d29454..00000000 Binary files a/frontend/assets/images/stop.png and /dev/null differ diff --git a/frontend/assets/images/window-close.png b/frontend/assets/images/window-close.png deleted file mode 100644 index bf043985..00000000 Binary files a/frontend/assets/images/window-close.png and /dev/null differ diff --git a/frontend/assets/images/window-maximize.png b/frontend/assets/images/window-maximize.png deleted file mode 100644 index b89047fc..00000000 Binary files a/frontend/assets/images/window-maximize.png and /dev/null differ diff --git a/frontend/assets/images/window-minimize.png b/frontend/assets/images/window-minimize.png deleted file mode 100644 index e97bf747..00000000 Binary files a/frontend/assets/images/window-minimize.png and /dev/null differ diff --git a/frontend/docs/README.md b/frontend/docs/README.md deleted file mode 100644 index 7f76ef26..00000000 --- a/frontend/docs/README.md +++ /dev/null @@ -1,17 +0,0 @@ -# Frontend Docs - -## English - -- [Overview](en/overview.md) -- [Usage](en/usage.md) -- [Development](en/development.md) -- [Updater](en/updater.md) -- [Build & packaging](en/build-and-packaging.md) - -## Tiếng Việt - -- [Tổng quan](vi/overview.md) -- [Hướng dẫn sử dụng](vi/usage.md) -- [Phát triển](vi/development.md) -- [Trình cập nhật](vi/updater.md) -- [Build & đóng gói](vi/build-and-packaging.md) diff --git a/frontend/docs/en/build-and-packaging.md b/frontend/docs/en/build-and-packaging.md deleted file mode 100644 index 61dfb9ac..00000000 --- a/frontend/docs/en/build-and-packaging.md +++ /dev/null @@ -1,66 +0,0 @@ -# Build & Packaging - -[🇻🇳 Vietnamese Version](../vi/build-and-packaging.md) - -This guide covers how to build the SlideGenerator application for production distribution. - -## Build Process Overview - -The build process consists of two main stages: -1. **Backend Build:** Compiling the .NET application into a self-contained executable. -2. **Frontend Build:** Bundling the React app and packaging it with Electron, including the backend binary. - -## 1. Building with Task (Recommended) - -The easiest way to build the project is using [Task](https://taskfile.dev/). - -**Build Full App:** -```bash -task build -``` - -**Build for Linux:** -```bash -task build RUNTIME=linux-x64 -``` - -This automates the process of building the backend, copying it to the frontend resources, and packaging the Electron app. - -## 2. Manual Build Steps - -If you prefer to run commands manually without Task: - -### Step 1: Build Backend - -The backend must be built first so it can be copied into the frontend's resource folder. - -Once the backend is ready, you can build the Electron app. - -**Command:** -```bash -# Run from frontend/ directory -npm run build:full -``` - -This script performs the following actions: -1. `build:backend`: Copies the published backend files to `frontend/backend`. -2. `build`: Runs Vite to bundle the React application. -3. `electron-builder`: Packages everything into an installer (NSIS for Windows, AppImage for Linux). - -## Distribution - -### Output Artifacts -The final installers are located in `frontend/release/`. - -- **Windows:** `SlideGenerator Setup .exe` -- **Linux:** `SlideGenerator-.AppImage` - -### Signing (Optional) -To sign the application (required for auto-updates and to avoid SmartScreen warnings): -1. Set `CSC_LINK` and `CSC_KEY_PASSWORD` environment variables. -2. Refer to [electron-builder documentation](https://www.electron.build/code-signing) for details. - -## Troubleshooting - -- **Missing Backend:** If the app launches but does nothing, ensure the backend binary was correctly copied to `resources/backend` inside the installed app. -- **Runtime Error:** Verify that the target machine meets the OS requirements (though the .NET runtime is self-contained, some OS dependencies might be needed on Linux). diff --git a/frontend/docs/en/development.md b/frontend/docs/en/development.md deleted file mode 100644 index d93498f8..00000000 --- a/frontend/docs/en/development.md +++ /dev/null @@ -1,89 +0,0 @@ -# Development Guide - -[🇻🇳 Vietnamese Version](../vi/development.md) - -## Environment Setup - -### Prerequisites -- **Node.js** (LTS recommended) -- **npm** (comes with Node.js) -- **.NET 10 SDK** (Required if you intend to run/debug the backend locally) - -### Installation -```bash -cd frontend -npm install -``` - -### Running in Development - -This command starts the Vite dev server and the Electron container. - -```bash -npm run dev -``` - -**Note:** By default, Electron attempts to spawn the backend process. -- **Disable Backend Spawn:** `SLIDEGEN_DISABLE_BACKEND=1` (Useful if you are running the backend in Visual Studio). -- **Custom Backend Path:** `SLIDEGEN_BACKEND_PATH=/path/to/executable`. - -## Project Structure - -We follow a **Feature-First** architecture. - -``` -src/ -├── app/ # App shell, Layouts, Providers -├── features/ # Feature modules -│ ├── create-task/ # Task creation wizard -│ ├── process/ # Job monitoring dashboard -│ ├── results/ # Completed jobs list -│ └── settings/ # App configuration -├── shared/ # Shared utilities -│ ├── components/ # Atomic UI components (Buttons, Inputs) -│ ├── contexts/ # React Contexts (JobContext, AppContext) -│ ├── hooks/ # Custom React Hooks -│ ├── services/ # API & SignalR clients -│ └── styles/ # Global SCSS & Variables -└── assets/ # Static assets (Images, Fonts) -``` - -## Coding Standards - -### TypeScript -- **Strict Mode:** Enabled. No `any` allowed unless absolutely necessary (and documented). -- **Interfaces:** Prefer `interface` over `type` for object definitions. -- **Naming:** PascalCase for components/interfaces, camelCase for functions/vars. - -### React -- **Functional Components:** Use FCs with Hooks. -- **Props:** Always define a typed Props interface. -- **Performance:** - - Use `React.memo` for list items or expensive components. - - Use `useCallback` for event handlers passed to child components. - -### Styling -- **CSS Modules:** Used for component-specific styles (`Component.module.scss`). -- **Global Styles:** Located in `src/shared/styles/`. Use CSS variables for theming. - -## Testing - -We use **Vitest** + **React Testing Library**. - -### Running Tests -```bash -npm test -``` - -### Writing Tests -- **Unit Tests:** Focus on utility functions and hooks. -- **Component Tests:** Focus on user interactions and accessibility. -- **Mocking:** Use MSW (Mock Service Worker) for network requests. Handlers are in `test/mocks/handlers.ts`. - -## Debugging - -- **Renderer Process:** Use standard Chrome DevTools (Ctrl+Shift+I). -- **Main Process:** Debug via VS Code "Debug Main Process" configuration. -- **Backend:** Debug via Visual Studio or VS Code C# extension. - -Next: [Build & Packaging](build-and-packaging.md) diff --git a/frontend/docs/en/overview.md b/frontend/docs/en/overview.md deleted file mode 100644 index 919da708..00000000 --- a/frontend/docs/en/overview.md +++ /dev/null @@ -1,85 +0,0 @@ -# Frontend Architecture - -[🇻🇳 Vietnamese Version](../vi/overview.md) - -## Purpose - -The SlideGenerator Frontend is a specialized desktop application designed to: -1. Provide a wizard-like interface for configuring complex slide generation jobs. -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. - -## High-Level Architecture - -The application follows a Feature-based folder structure, ensuring scalability and maintainability. - -```mermaid -graph TD - AppShell --> Features - Features --> Shared - Shared --> Services - Services --> SignalR - SignalR --> Backend -``` - -### 1. Application Layer (`src/app`) -Responsible for the app's lifecycle and global context. -- **Routing:** Manages navigation between tabs (Create, Process, Results). -- **Providers:** Wraps the app with `ThemeProvider`, `ToastProvider`, etc. -- **Layout:** Defines the standard window frame (Sidebar, TitleBar). - -### 2. Feature Layer (`src/features`) -Contains the UI logic for specific user workflows. -- **`create-task`**: Multi-step form for job inputs. -- **`process`**: Dashboard showing progress bars and status indicators. -- **`results`**: List of completed jobs with file actions (Open, Explorer). -- **`settings`**: Configuration UI for backend and app preferences. - -### 3. Shared Layer (`src/shared`) -Reusable components and utilities. -- **`components`**: Generic UI elements (Buttons, Inputs, Modals). -- **`contexts`**: Global state containers (`AppContext`, `JobContext`). -- **`services`**: API clients and SignalR 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. - -### 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. - -## 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. -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. -7. **Re-render:** `process` feature re-renders to show the new job in the list. - -## Performance Strategies - -- **Virtualization:** (Planned) For efficiently rendering large lists of jobs. -- **Memoization:** `React.memo` and `useMemo` are aggressively used in `JobItem` components to prevent unnecessary re-renders during rapid progress updates. -- **Log Trimming:** The in-memory log buffer is capped (default 2500 lines) to prevent memory leaks in long-running sessions. - -## Storage - -We use `localStorage` and `sessionStorage` for non-critical persistence: - -| Key | Storage | Description | -| :--- | :--- | :--- | -| `slidegen.backend.url` | Local | The active Backend URL. | -| `slidegen.theme` | Local | UI Theme preference (Dark/Light). | -| `slidegen.ui.inputsideBar.state` | Session | Draft state of the Create Task form. | - -Next: [Development Guide](development.md) diff --git a/frontend/docs/en/updater.md b/frontend/docs/en/updater.md deleted file mode 100644 index 1034c16b..00000000 --- a/frontend/docs/en/updater.md +++ /dev/null @@ -1,67 +0,0 @@ -# Updater - -[Tiếng Việt](../vi/updater.md) - -The application uses `electron-updater` to provide automated updates for non-portable Windows builds. - -## Features - -- **Automatic Check**: Checks for updates on application startup. -- **Manual Check**: Users can trigger a check from the About screen. -- **Differential Downloads**: Only downloads what has changed (standard `electron-updater` behavior). -- **Safety Guard**: Prevents installation if there are active slide generation jobs. -- **Portable Detection**: Automatically disables updates when running from a portable executable. - -## Architecture - -### Main Process (`electron/main/updater.ts`) - -- Manages the `autoUpdater` instance. -- Handles IPC calls for checking, downloading, and installing updates. -- Broadcasts status updates to all renderer windows via `updater:status`. -- Persists "downloaded" state to handle app restarts. - -### Preload (`electron/preload/api.ts`) - -- Exposes typed methods to the renderer via `window.electronAPI`: - - `checkForUpdates()` - - `downloadUpdate()` - - `installUpdate()` - - `onUpdateStatus(callback)` - - `isPortable()` - -### React Context (`src/shared/contexts/UpdaterContext.tsx`) - -- Provides `useUpdater()` hook. -- Synchronizes local state with IPC events. -- Tracks `hasActiveJobs` to gate the installation process. - -## Update Flow - -1. **Check**: Application calls `checkForUpdates`. Status transitions to `checking`. -2. **Available**: If a newer version is found, status becomes `available`. -3. **Download**: User clicks download. Status becomes `downloading` with progress percentage. -4. **Ready**: Once downloaded, status becomes `downloaded`. -5. **Install**: User clicks install. The app calls `quitAndInstall()`. - - _Note_: The UI disables the Install button if `hasActiveJobs` is true. - -## Configuration - -The updater configuration is read from `package.json` under `build.publish`. - -```json -"build": { - "publish": { - "provider": "github", - "owner": "your-username", - "repo": "your-repo" - } -} -``` - -## Testing in Development - -The updater is configured to allow testing in development mode: - -- `autoUpdater.forceDevUpdateConfig = true` is set when `app.isPackaged` is false. -- A `dev-app-update.yml` may be required in the root for local testing. diff --git a/frontend/docs/en/usage.md b/frontend/docs/en/usage.md deleted file mode 100644 index 1fd067c9..00000000 --- a/frontend/docs/en/usage.md +++ /dev/null @@ -1,61 +0,0 @@ -# Usage Guide - -[Tiếng Việt](../vi/usage.md) - -## Prerequisites - -- Backend is running (Electron can start it automatically). -- Template: `.pptx` or `.potx`. -- Spreadsheet: `.xlsx` or `.xlsm`. - -## Connect to backend - -1. Open **Settings**. -2. Check host/port (defaults to local). -3. Save changes and restart backend if prompted. - -## Create a task (group job) - -1. Choose a PowerPoint template. -2. Choose a spreadsheet and wait for columns/sheets to load. -3. Add text and image replacements. -4. Optionally pick specific sheets to process. -5. Choose output folder. -6. Click **Create Task**. - -Notes: - -- A group job represents one template + one workbook + one output folder. -- A sheet job represents one sheet inside the group. -- Progress and counts are based on slide rows, not the number of jobs. - -## Processing - -Use **Processing** to: - -- Pause/resume group or sheet jobs. -- Cancel jobs. -- View row-level logs grouped by row. - -## Results - -Use **Results** to: - -- View completed/failed/cancelled groups. -- Open output folder or file. -- Remove a group or sheet (also clears backend state). - -## Export/import configs - -- **Create Task** supports JSON export/import for reuse. -- Each group has an export action for quick sharing. - -## Check for updates - -Use **About** to check for and install application updates: - -1. Open the **About** tab. -2. Click **Check for updates** to check for new versions. -3. If an update is available, click **Download and install**. -4. After downloading, click **Install now** to restart and apply the update. -5. Alternatively, the update will be applied when you quit the application. diff --git a/frontend/docs/vi/build-and-packaging.md b/frontend/docs/vi/build-and-packaging.md deleted file mode 100644 index c2bb363e..00000000 --- a/frontend/docs/vi/build-and-packaging.md +++ /dev/null @@ -1,66 +0,0 @@ -# Build & Đóng gói - -[🇺🇸 English Version](../en/build-and-packaging.md) - -Hướng dẫn này bao gồm cách build ứng dụng SlideGenerator để phân phối sản phẩm (production). - -## Tổng quan Quy trình Build - -Quy trình build bao gồm hai giai đoạn chính: -1. **Backend Build:** Biên dịch ứng dụng .NET thành file thực thi khép kín (self-contained executable). -2. **Frontend Build:** Đóng gói ứng dụng React và Electron, bao gồm cả binary backend. - -## 1. Build với Task (Khuyên dùng) - -Cách dễ nhất để build dự án là sử dụng [Task](https://taskfile.dev/). - -**Build Toàn bộ:** -```bash -task build -``` - -**Build cho Linux:** -```bash -task build RUNTIME=linux-x64 -``` - -Lệnh này tự động hóa quy trình build backend, copy vào resource frontend, và đóng gói ứng dụng Electron. - -## 2. Quy trình Build Thủ công - -Nếu bạn muốn chạy lệnh thủ công mà không dùng Task: - -### Bước 1: Build Backend - -Backend phải được build trước để có thể copy vào thư mục resource của frontend. - -Khi backend đã sẵn sàng, bạn có thể build ứng dụng Electron. - -**Lệnh:** -```bash -# Chạy từ thư mục frontend/ -npm run build:full -``` - -Script này thực hiện các hành động sau: -1. `build:backend`: Copy các file backend đã publish vào `frontend/backend`. -2. `build`: Chạy Vite để đóng gói ứng dụng React. -3. `electron-builder`: Đóng gói mọi thứ thành bộ cài đặt (NSIS cho Windows, AppImage cho Linux). - -## Phân phối - -### Artifact đầu ra -Các bộ cài đặt cuối cùng nằm tại `frontend/release/`. - -- **Windows:** `SlideGenerator Setup .exe` -- **Linux:** `SlideGenerator-.AppImage` - -### Signing (Tùy chọn) -Để ký ứng dụng (bắt buộc cho auto-update và tránh cảnh báo SmartScreen): -1. Thiết lập biến môi trường `CSC_LINK` và `CSC_KEY_PASSWORD`. -2. Tham khảo [tài liệu electron-builder](https://www.electron.build/code-signing) để biết chi tiết. - -## Khắc phục sự cố - -- **Thiếu Backend:** Nếu ứng dụng chạy nhưng không làm gì cả, hãy đảm bảo binary backend đã được copy chính xác vào `resources/backend` bên trong ứng dụng đã cài đặt. -- **Lỗi Runtime:** Kiểm tra xem máy đích có đáp ứng yêu cầu hệ điều hành không (mặc dù .NET runtime là khép kín, một số dependency hệ điều hành có thể cần thiết trên Linux). diff --git a/frontend/docs/vi/development.md b/frontend/docs/vi/development.md deleted file mode 100644 index 7db66d52..00000000 --- a/frontend/docs/vi/development.md +++ /dev/null @@ -1,89 +0,0 @@ -# Hướng dẫn Phát triển - -[🇺🇸 English Version](../en/development.md) - -## Thiết lập Môi trường - -### Yêu cầu tiên quyết -- **Node.js** (Khuyên dùng bản LTS) -- **npm** (đi kèm với Node.js) -- **.NET 10 SDK** (Cần thiết nếu bạn định chạy/debug backend cục bộ) - -### Cài đặt -```bash -cd frontend -npm install -``` - -### Chạy trong môi trường Dev - -Lệnh này khởi động Vite dev server và Electron container. - -```bash -npm run dev -``` - -**Lưu ý:** Mặc định, Electron sẽ cố gắng khởi chạy tiến trình backend. -- **Tắt khởi chạy Backend:** `SLIDEGEN_DISABLE_BACKEND=1` (Hữu ích khi bạn đang chạy backend riêng trong Visual Studio). -- **Đường dẫn Backend tùy chỉnh:** `SLIDEGEN_BACKEND_PATH=/path/to/executable`. - -## Cấu trúc Dự án - -Chúng tôi tuân theo kiến trúc **Feature-First** (Ưu tiên tính năng). - -``` -src/ -├── app/ # App shell, Layouts, Providers -├── features/ # Feature modules -│ ├── create-task/ # Wizard tạo task -│ ├── process/ # Dashboard giám sát job -│ ├── results/ # Danh sách job hoàn thành -│ └── settings/ # Cấu hình ứng dụng -├── shared/ # Tiện ích chia sẻ -│ ├── components/ # UI components nguyên tử (Buttons, Inputs) -│ ├── contexts/ # React Contexts (JobContext, AppContext) -│ ├── hooks/ # Custom React Hooks -│ ├── services/ # API & SignalR clients -│ └── styles/ # Global SCSS & Variables -└── assets/ # Tài nguyên tĩnh (Images, Fonts) -``` - -## Tiêu chuẩn Coding - -### TypeScript -- **Strict Mode:** Đã bật. Không được dùng `any` trừ khi thực sự cần thiết (và phải có comment giải thích). -- **Interfaces:** Ưu tiên dùng `interface` hơn `type` cho các định nghĩa object. -- **Đặt tên:** PascalCase cho components/interfaces, camelCase cho functions/vars. - -### React -- **Functional Components:** Sử dụng FC với Hooks. -- **Props:** Luôn định nghĩa interface Props có kiểu. -- **Hiệu năng:** - - Sử dụng `React.memo` cho các item trong danh sách hoặc component nặng. - - Sử dụng `useCallback` cho các event handler được truyền xuống component con. - -### Styling -- **CSS Modules:** Sử dụng cho style riêng của component (`Component.module.scss`). -- **Global Styles:** Nằm trong `src/shared/styles/`. Sử dụng biến CSS cho theming. - -## Testing - -Chúng tôi sử dụng **Vitest** + **React Testing Library**. - -### Chạy Test -```bash -npm test -``` - -### Viết Test -- **Unit Tests:** Tập trung vào các hàm tiện ích (utility functions) và hooks. -- **Component Tests:** Tập trung vào tương tác người dùng và khả năng truy cập (accessibility). -- **Mocking:** Sử dụng MSW (Mock Service Worker) cho các request mạng. Handlers nằm trong `test/mocks/handlers.ts`. - -## Debugging - -- **Renderer Process:** Sử dụng Chrome DevTools tiêu chuẩn (Ctrl+Shift+I). -- **Main Process:** Debug qua cấu hình "Debug Main Process" của VS Code. -- **Backend:** Debug qua Visual Studio hoặc extension C# của VS Code. - -Tiếp theo: [Build & Đóng gói](build-and-packaging.md) diff --git a/frontend/docs/vi/overview.md b/frontend/docs/vi/overview.md deleted file mode 100644 index f83d0a53..00000000 --- a/frontend/docs/vi/overview.md +++ /dev/null @@ -1,85 +0,0 @@ -# Kiến trúc Frontend - -[🇺🇸 English Version](../en/overview.md) - -## Mục đích - -Frontend của SlideGenerator là một ứng dụng desktop chuyên biệt được thiết kế để: -1. Cung cấp giao diện dạng wizard để cấu hình các job tạo slide phức tạp. -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. - -## Kiến trúc Mức cao - -Ứng dụng tuân theo cấu trúc thư mục dựa trên Tính năng (Feature-based), đảm bảo khả năng mở rộng và bảo trì. - -```mermaid -graph TD - AppShell --> Features - Features --> Shared - Shared --> Services - Services --> SignalR - SignalR --> Backend -``` - -### 1. Tầng Ứng dụng (`src/app`) -Chịu trách nhiệm về vòng đời ứng dụng và context toàn cục. -- **Routing:** Quản lý điều hướng giữa các tab (Create, Process, Results). -- **Providers:** Bọc ứng dụng với `ThemeProvider`, `ToastProvider`, v.v. -- **Layout:** Định nghĩa khung cửa sổ tiêu chuẩn (Sidebar, TitleBar). - -### 2. Tầng Tính năng (`src/features`) -Chứa logic UI cho các luồng công việc cụ thể của người dùng. -- **`create-task`**: Form đa bước để nhập liệu cho job. -- **`process`**: Dashboard hiển thị thanh tiến trình và trạng thái. -- **`results`**: Danh sách các job đã hoàn thành với các hành động file (Mở, Xem trong Explorer). -- **`settings`**: Giao diện cấu hình cho backend và tùy chọn ứng dụng. - -### 3. Tầng Chia sẻ (`src/shared`) -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. - -## 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. - -### 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. - -## 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. -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. -7. **Re-render:** Tính năng `process` render lại để hiển thị job mới trong danh sách. - -## Chiến lược Hiệu năng - -- **Virtualization:** (Dự kiến) Để render hiệu quả danh sách job lớn. -- **Memoization:** `React.memo` và `useMemo` được sử dụng tích cực trong các component `JobItem` để ngăn chặn re-render không cần thiết khi cập nhật tiến độ nhanh. -- **Log Trimming:** Bộ đệm log trong bộ nhớ được giới hạn (mặc định 2500 dòng) để ngăn rò rỉ bộ nhớ trong các phiên làm việc dài. - -## Lưu trữ - -Chúng tôi sử dụng `localStorage` và `sessionStorage` cho các dữ liệu không quan trọng: - -| Key | Storage | Mô tả | -| :--- | :--- | :--- | -| `slidegen.backend.url` | Local | URL Backend đang hoạt động. | -| `slidegen.theme` | Local | Tùy chọn giao diện (Tối/Sáng). | -| `slidegen.ui.inputsideBar.state` | Session | Trạng thái nháp của form Tạo Task. | - -Tiếp theo: [Hướng dẫn Phát triển](development.md) diff --git a/frontend/docs/vi/updater.md b/frontend/docs/vi/updater.md deleted file mode 100644 index 988e6c16..00000000 --- a/frontend/docs/vi/updater.md +++ /dev/null @@ -1,67 +0,0 @@ -# Trình cập nhật (Updater) - -[English](../en/updater.md) - -Ứng dụng sử dụng `electron-updater` để cung cấp khả năng cập nhật tự động cho các bản dựng Windows (không bao gồm bản portable). - -## Tính năng - -- **Kiểm tra tự động**: Tự động kiểm tra bản cập nhật khi khởi động ứng dụng. -- **Kiểm tra thủ công**: Người dùng có thể kích hoạt kiểm tra từ màn hình Giới thiệu (About). -- **Tải xuống vi sai**: Chỉ tải xuống những phần thay đổi (hành vi mặc định của `electron-updater`). -- **Bảo vệ an toàn**: Ngăn chặn việc cài đặt nếu đang có các tiến trình tạo slide đang chạy. -- **Phát hiện bản Portable**: Tự động vô hiệu hóa tính năng cập nhật khi chạy từ tệp thực thi portable. - -## Kiến trúc - -### Tiến trình chính (Main Process - `electron/main/updater.ts`) - -- Quản lý thực thể `autoUpdater`. -- Xử lý các cuộc gọi IPC để kiểm tra, tải xuống và cài đặt bản cập nhật. -- Phát tín hiệu trạng thái tới tất cả các cửa sổ renderer thông qua `updater:status`. -- Lưu giữ trạng thái "đã tải xuống" để xử lý khi ứng dụng khởi động lại. - -### Preload (`electron/preload/api.ts`) - -- Cung cấp các phương thức đã được định nghĩa kiểu cho renderer thông qua `window.electronAPI`: - - `checkForUpdates()` - - `downloadUpdate()` - - `installUpdate()` - - `onUpdateStatus(callback)` - - `isPortable()` - -### React Context (`src/shared/contexts/UpdaterContext.tsx`) - -- Cung cấp hook `useUpdater()`. -- Đồng bộ hóa trạng thái cục bộ với các sự kiện IPC. -- Theo dõi `hasActiveJobs` để kiểm soát quá trình cài đặt. - -## Quy trình cập nhật - -1. **Kiểm tra**: Ứng dụng gọi `checkForUpdates`. Trạng thái chuyển sang `checking`. -2. **Có bản mới**: Nếu tìm thấy phiên bản mới hơn, trạng thái trở thành `available`. -3. **Tải xuống**: Người dùng nhấn tải xuống. Trạng thái trở thành `downloading` kèm theo phần trăm tiến độ. -4. **Sẵn sàng**: Sau khi tải xong, trạng thái trở thành `downloaded`. -5. **Cài đặt**: Người dùng nhấn cài đặt. Ứng dụng gọi `quitAndInstall()`. - - _Lưu ý_: Giao diện sẽ vô hiệu hóa nút Cài đặt nếu `hasActiveJobs` là true. - -## Cấu hình - -Cấu hình của trình cập nhật được đọc từ `package.json` trong mục `build.publish`. - -```json -"build": { - "publish": { - "provider": "github", - "owner": "your-username", - "repo": "your-repo" - } -} -``` - -## Kiểm thử trong môi trường phát triển - -Trình cập nhật được cấu hình để cho phép kiểm thử trong chế độ phát triển: - -- `autoUpdater.forceDevUpdateConfig = true` được thiết lập khi `app.isPackaged` là false. -- Có thể cần tệp `dev-app-update.yml` ở thư mục gốc để kiểm thử cục bộ. diff --git a/frontend/docs/vi/usage.md b/frontend/docs/vi/usage.md deleted file mode 100644 index d65dadb1..00000000 --- a/frontend/docs/vi/usage.md +++ /dev/null @@ -1,61 +0,0 @@ -# Hướng dẫn sử dụng - -[English](../en/usage.md) - -## Điều kiện - -- Backend đang chạy (Electron có thể tự khởi động). -- Template: `.pptx` hoặc `.potx`. -- Spreadsheet: `.xlsx` hoặc `.xlsm`. - -## Kết nối backend - -1. Mở **Settings**. -2. Kiểm tra host/port (mặc định là local). -3. Lưu và restart backend nếu được yêu cầu. - -## Tạo task (group job) - -1. Chọn template PowerPoint. -2. Chọn file Excel và chờ tải cột/sheet. -3. Thiết lập text/image replacements. -4. Chọn sheet cần xử lý (tuỳ chọn). -5. Chọn thư mục output. -6. Nhấn **Create Task**. - -Ghi chú: - -- Group job đại diện một template + một workbook + một thư mục output. -- Sheet job là từng sheet nằm trong group. -- Progress và thống kê dựa trên số row/slide, không dựa trên số job. - -## Xử lý - -Trong **Processing** bạn có thể: - -- Pause/Resume group hoặc sheet. -- Cancel job. -- Xem log theo từng row. - -## Kết quả - -Trong **Results** bạn có thể: - -- Xem nhóm hoàn thành/lỗi/huỷ. -- Mở thư mục hoặc file output. -- Xoá group/sheet (đồng thời xoá state trên backend). - -## Export/import config - -- **Create Task** hỗ trợ export/import JSON. -- Mỗi group có chức năng export config nhanh. - -## Kiểm tra cập nhật - -Trong tab **Giới thiệu** bạn có thể kiểm tra và cài đặt bản cập nhật: - -1. Mở tab **Giới thiệu**. -2. Nhấn **Kiểm tra cập nhật** để kiểm tra phiên bản mới. -3. Nếu có bản cập nhật, nhấn **Tải và cài đặt**. -4. Sau khi tải xong, nhấn **Cài đặt ngay** để khởi động lại và áp dụng bản cập nhật. -5. Hoặc bản cập nhật sẽ được áp dụng khi bạn thoát ứng dụng. diff --git a/frontend/electron/main.ts b/frontend/electron/main.ts deleted file mode 100644 index eb29340c..00000000 --- a/frontend/electron/main.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { app, BrowserWindow, ipcMain } from 'electron'; -import path from 'path'; -import { fileURLToPath } from 'url'; -import { getAssetPath, registerAssetHandlers } from './main/assets'; -import { createBackendController } from './main/backend'; -import { registerDialogHandlers } from './main/dialogs'; -import { attachProcessOutputCapture, initLogging, registerRendererLogIpc } from './main/logging'; -import { registerSettingsHandlers } from './main/settings'; -import { registerUpdaterHandlers } from './main/updater'; -import { createMainWindow, registerWindowHandlers, setIsQuitting } from './main/window'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -const isDev = process.env.NODE_ENV === 'development'; -const preloadPath = path.join(__dirname, 'preload.js'); -const indexPath = path.join(__dirname, '../dist/index.html'); -const devUrl = 'http://localhost:65000'; - -const logPaths = initLogging(); -attachProcessOutputCapture(logPaths.processLogPath); -registerRendererLogIpc(logPaths.rendererLogPath); - -registerAssetHandlers(); -registerDialogHandlers(); -registerSettingsHandlers(); -registerWindowHandlers(getAssetPath); -registerUpdaterHandlers(); - -const backendController = createBackendController(logPaths.backendLogPath); - -app.commandLine.appendSwitch('remote-debugging-port', '9222'); - -app.whenReady().then(() => { - backendController.startBackend(); - createMainWindow({ - preloadPath, - getAssetPath, - isDev, - devUrl, - indexPath, - }); - - app.on('activate', () => { - if (BrowserWindow.getAllWindows().length === 0) { - createMainWindow({ - preloadPath, - getAssetPath, - isDev, - devUrl, - indexPath, - }); - } - }); -}); - -app.on('window-all-closed', () => { - if (process.platform !== 'darwin') { - app.quit(); - } -}); - -let isAppQuitting = false; - -app.on('before-quit', async (event) => { - if (isAppQuitting) return; - - event.preventDefault(); - isAppQuitting = true; - setIsQuitting(true); - await backendController.stopBackend(); - app.quit(); -}); - -ipcMain.handle('backend:restart', async () => { - return backendController.restartBackend(); -}); diff --git a/frontend/electron/main/assets.ts b/frontend/electron/main/assets.ts deleted file mode 100644 index 272b1e33..00000000 --- a/frontend/electron/main/assets.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { app, ipcMain } from 'electron'; -import path from 'path'; - -export const getAssetPath = (...parts: string[]): string => { - if (process.env.NODE_ENV === 'development') { - return path.join('assets', ...parts).replace(/\\/g, '/'); - } - const base = app.getAppPath(); - return path.join(base, 'assets', ...parts); -}; - -export const registerAssetHandlers = () => { - ipcMain.handle('assets:getPath', async (_, ...parts: string[]) => { - return getAssetPath(...parts); - }); - - ipcMain.on('assets:getPathSync', (event, ...parts: string[]) => { - event.returnValue = getAssetPath(...parts); - }); -}; diff --git a/frontend/electron/main/backend.ts b/frontend/electron/main/backend.ts deleted file mode 100644 index 6bd8a1b4..00000000 --- a/frontend/electron/main/backend.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { app } from 'electron'; -import { spawn, ChildProcess } from 'child_process'; -import path from 'path'; -import fsSync from 'fs'; -import log from 'electron-log'; - -interface BackendLaunch { - command: string; - args: string[]; - cwd: string; -} - -const shouldStartBackend = () => { - if (process.env.NODE_ENV === 'development') return false; - return process.env.SLIDEGEN_DISABLE_BACKEND !== '1'; -}; - -const resolveBackendCommand = (): BackendLaunch | null => { - const override = process.env.SLIDEGEN_BACKEND_PATH; - - if (override && fsSync.existsSync(override)) { - const ext = path.extname(override).toLowerCase(); - - if (ext === '.dll') { - return { - command: 'dotnet', - args: [override], - cwd: path.dirname(override), - }; - } - - return { - command: override, - args: [], - cwd: path.dirname(override), - }; - } - - if (app.isPackaged) { - const backendRoot = path.join(process.resourcesPath, 'backend'); - const exePath = path.join(backendRoot, 'SlideGenerator.Presentation.exe'); - - if (fsSync.existsSync(exePath)) { - return { command: exePath, args: [], cwd: backendRoot }; - } - - const dllPath = path.join(backendRoot, 'SlideGenerator.Presentation.dll'); - if (fsSync.existsSync(dllPath)) { - return { command: 'dotnet', args: [dllPath], cwd: backendRoot }; - } - } - - return null; -}; - -export const createBackendController = (backendLogPath: string) => { - let backendProcess: ChildProcess | null = null; - - const startBackend = () => { - if (!shouldStartBackend() || backendProcess) return; - const launch = resolveBackendCommand(); - if (!launch) return; - - backendProcess = spawn(launch.command, launch.args, { - cwd: launch.cwd, - windowsHide: true, - stdio: 'ignore', - detached: false, - env: { - ...process.env, - SLIDEGEN_LOG_PATH: backendLogPath, - }, - }); - - backendProcess.on('exit', (code) => { - log.info(`Backend process exited with code ${code}`); - backendProcess = null; - }); - }; - - const stopBackend = async () => { - if (!backendProcess) return; - const proc = backendProcess; - backendProcess = null; - - return new Promise((resolve) => { - const timeout = setTimeout(() => { - if (!proc.killed) { - log.info('Backend timed out, force killing...'); - proc.kill(); - } - resolve(); - }, 5000); - - proc.once('exit', () => { - clearTimeout(timeout); - resolve(); - }); - - try { - proc.kill('SIGINT'); - setTimeout(() => { - if (!proc.killed) proc.kill('SIGTERM'); - }, 1000); - } catch (error) { - log.error('Error stopping backend:', error); - proc.kill(); - resolve(); - } - }); - }; - - const restartBackend = async () => { - await stopBackend(); - startBackend(); - return Boolean(backendProcess); - }; - - return { - startBackend, - stopBackend, - restartBackend, - }; -}; diff --git a/frontend/electron/main/dialogs.ts b/frontend/electron/main/dialogs.ts deleted file mode 100644 index f5b4fb4c..00000000 --- a/frontend/electron/main/dialogs.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { dialog, ipcMain, shell } from 'electron'; - -export const registerDialogHandlers = () => { - ipcMain.handle('dialog:openFile', async (_, filters: Electron.FileFilter[]) => { - const result = await dialog.showOpenDialog({ - properties: ['openFile'], - filters: filters || [{ name: 'All Files', extensions: ['*'] }], - }); - return result.filePaths[0]; - }); - - ipcMain.handle('dialog:openMultipleFiles', async (_, filters: Electron.FileFilter[]) => { - const result = await dialog.showOpenDialog({ - properties: ['openFile', 'multiSelections'], - filters: filters || [{ name: 'All Files', extensions: ['*'] }], - }); - return result.filePaths; - }); - - ipcMain.handle('dialog:openFolder', async () => { - const result = await dialog.showOpenDialog({ - properties: ['openDirectory'], - }); - return result.filePaths[0]; - }); - - ipcMain.handle('dialog:saveFile', async (_, filters: Electron.FileFilter[]) => { - const result = await dialog.showSaveDialog({ - filters: filters || [{ name: 'All Files', extensions: ['*'] }], - defaultPath: 'task-config.json', - }); - return result.filePath; - }); - - ipcMain.handle('dialog:openUrl', async (_, url: string) => { - await shell.openExternal(url); - }); - - ipcMain.handle('dialog:openPath', async (_, filePath: string) => { - await shell.openPath(filePath); - }); -}; diff --git a/frontend/electron/main/logging.ts b/frontend/electron/main/logging.ts deleted file mode 100644 index 15b4cdee..00000000 --- a/frontend/electron/main/logging.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { app, ipcMain } from 'electron'; -import path from 'path'; -import fsSync from 'fs'; -import { promises as fs } from 'fs'; -import log from 'electron-log'; - -export interface LogPaths { - sessionLogFolder: string; - processLogPath: string; - rendererLogPath: string; - backendLogPath: string; -} - -/** Maximum age of log folders in days before cleanup */ -const LOG_RETENTION_DAYS = 30; - -const padNumber = (value: number, length = 2) => String(value).padStart(length, '0'); -const formatTimestamp = (time = new Date()) => { - return ( - `${time.getFullYear()}-${padNumber(time.getMonth() + 1)}-${padNumber(time.getDate())} ` + - `${padNumber(time.getHours())}:${padNumber(time.getMinutes())}:${padNumber(time.getSeconds())}.` + - padNumber(time.getMilliseconds(), 3) - ); -}; - -const formatFolderTimestamp = (time = new Date()) => { - return ( - `${time.getFullYear()}-${padNumber(time.getMonth() + 1)}-${padNumber(time.getDate())}_` + - `${padNumber(time.getHours())}-${padNumber(time.getMinutes())}-${padNumber(time.getSeconds())}-` + - padNumber(time.getMilliseconds(), 3) - ); -}; - -export const initLogging = (): LogPaths => { - const runTimestamp = formatFolderTimestamp(); - const appFolder = app.isPackaged ? path.dirname(app.getPath('exe')) : process.cwd(); - const logFolder = path.join(appFolder, 'logs'); - const sessionLogFolder = path.join(logFolder, runTimestamp); - - if (!fsSync.existsSync(sessionLogFolder)) { - fsSync.mkdirSync(sessionLogFolder, { recursive: true }); - } - - const processLogPath = path.join(sessionLogFolder, 'process.log'); - const rendererLogPath = path.join(sessionLogFolder, 'renderer.log'); - const backendLogPath = path.join(sessionLogFolder, 'backend.log'); - - log.initialize({ preload: true }); - const fileTransport = log.transports?.file as unknown as - | { - resolvePathFn?: (variables: unknown, message?: unknown) => string; - format?: string; - } - | undefined; - if (fileTransport) { - fileTransport.resolvePathFn = () => processLogPath; - // Format matching backend: timestamp [LEVEL] [Source] message - fileTransport.format = '{y}-{m}-{d} {h}:{i}:{s}.{ms} [{level}] [Main] {text}'; - } - - // Also update console format for consistency - const consoleTransport = log.transports?.console as unknown as { format?: string } | undefined; - if (consoleTransport) { - consoleTransport.format = '{y}-{m}-{d} {h}:{i}:{s}.{ms} [{level}] [Main] {text}'; - } - - Object.assign(console, log.functions); - log.info('Process logger initialized'); - - // Cleanup old log folders asynchronously - cleanupOldLogs(logFolder).catch((error) => { - log.warn('[Logging] Failed to cleanup old logs:', error); - }); - - return { sessionLogFolder, processLogPath, rendererLogPath, backendLogPath }; -}; - -export const attachProcessOutputCapture = (processLogPath: string) => { - const appendProcessOutput = (chunk: unknown) => { - if (typeof chunk !== 'string' && !Buffer.isBuffer(chunk)) return; - const text = typeof chunk === 'string' ? chunk : chunk.toString('utf-8'); - if (!text) return; - fs.appendFile(processLogPath, text).catch(() => undefined); - }; - - const stdoutWrite = process.stdout.write.bind(process.stdout) as (...args: unknown[]) => boolean; - const stderrWrite = process.stderr.write.bind(process.stderr) as (...args: unknown[]) => boolean; - process.stdout.write = ((chunk: unknown, ...args: unknown[]) => { - appendProcessOutput(chunk); - return stdoutWrite(chunk, ...args); - }) as typeof process.stdout.write; - process.stderr.write = ((chunk: unknown, ...args: unknown[]) => { - appendProcessOutput(chunk); - return stderrWrite(chunk, ...args); - }) as typeof process.stderr.write; -}; - -export const registerRendererLogIpc = (rendererLogPath: string) => { - ipcMain.on( - 'logs:renderer', - (_, payload: { level?: string; message?: string; source?: string }) => { - const level = payload?.level ?? 'info'; - const source = payload?.source ?? 'Renderer'; - const message = payload?.message ?? ''; - const line = `${formatTimestamp()} [${level}] [${source}] ${message}\n`; - fs.appendFile(rendererLogPath, line).catch((error) => { - log.warn('Failed to append renderer log:', error); - }); - }, - ); -}; - -/** - * Removes log folders older than LOG_RETENTION_DAYS. - * Folders are expected to be named with timestamp format: YYYY-MM-DD_HH-MM-SS-mmm - */ -const cleanupOldLogs = async (logFolder: string): Promise => { - const now = Date.now(); - const maxAge = LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000; - - let entries: fsSync.Dirent[]; - try { - entries = await fs.readdir(logFolder, { withFileTypes: true }); - } catch { - return; // Log folder doesn't exist yet - } - - const folderPattern = /^(\d{4})-(\d{2})-(\d{2})_(\d{2})-(\d{2})-(\d{2})-(\d{3})$/; - - for (const entry of entries) { - if (!entry.isDirectory()) continue; - - const match = folderPattern.exec(entry.name); - if (!match) continue; - - const [, year, month, day, hour, minute, second] = match.map(Number); - const folderDate = new Date(year, month - 1, day, hour, minute, second); - const age = now - folderDate.getTime(); - - if (age > maxAge) { - const folderPath = path.join(logFolder, entry.name); - try { - await fs.rm(folderPath, { recursive: true, force: true }); - log.info(`[Logging] Removed old log folder: ${entry.name}`); - } catch (error) { - log.warn(`[Logging] Failed to remove old log folder ${entry.name}:`, error); - } - } - } -}; diff --git a/frontend/electron/main/settings.ts b/frontend/electron/main/settings.ts deleted file mode 100644 index 80ab1008..00000000 --- a/frontend/electron/main/settings.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { app, ipcMain } from 'electron'; -import path from 'path'; -import { promises as fs } from 'fs'; - -export const registerSettingsHandlers = () => { - ipcMain.handle('settings:read', async (_, filename: string) => { - try { - const settingsPath = path.isAbsolute(filename) - ? filename - : path.join(app.getPath('userData'), filename); - const data = await fs.readFile(settingsPath, 'utf-8'); - return data; - } catch (_error) { - return null; - } - }); - - ipcMain.handle('settings:write', async (_, filename: string, data: string) => { - try { - const settingsPath = path.isAbsolute(filename) - ? filename - : path.join(app.getPath('userData'), filename); - await fs.writeFile(settingsPath, data, 'utf-8'); - return true; - } catch (error) { - console.error('Error writing settings:', error); - return false; - } - }); -}; diff --git a/frontend/electron/main/updater.ts b/frontend/electron/main/updater.ts deleted file mode 100644 index c384f4ad..00000000 --- a/frontend/electron/main/updater.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { app, BrowserWindow, ipcMain } from 'electron'; -import { autoUpdater, UpdateInfo } from 'electron-updater'; -import log from 'electron-log'; -import * as path from 'path'; -import * as fs from 'fs'; - -export type UpdateStatus = - | 'checking' - | 'available' - | 'not-available' - | 'downloading' - | 'downloaded' - | 'error' - | 'unsupported'; // portable - -export interface UpdateState { - status: UpdateStatus; - info?: UpdateInfo; - progress?: number; - error?: string; -} - -/** Detect portable mode via env set by portable launcher/build. */ -function isPortableMode(): boolean { - return !!process.env.PORTABLE_EXECUTABLE_DIR; -} - -/** Read updater publish config from package.json (custom field). */ -function readUpdateConfigFromPackageJson(): { - provider: string; - owner: string; - repo: string; - releaseType?: string; -} { - const pkgPath = path.join(process.resourcesPath, 'app.asar', 'package.json'); - const fallbackPkgPath = path.join(require('electron').app.getAppPath(), 'package.json'); - - const read = (p: string) => JSON.parse(fs.readFileSync(p, 'utf-8')); - const pkg = fs.existsSync(pkgPath) ? read(pkgPath) : read(fallbackPkgPath); - const release = pkg.build.publish; - - return release; -} - -/** Persist downloaded version to make "downloaded" version-aware. */ -function getMetaPath(): string { - const { app } = require('electron'); - return path.join(app.getPath('userData'), 'pending', 'update.json'); -} - -function getDownloadedVersion(): string | null { - try { - const p = getMetaPath(); - if (!fs.existsSync(p)) return null; - const meta = JSON.parse(fs.readFileSync(p, 'utf-8')) as { version?: string }; - return meta.version ?? null; - } catch { - return null; - } -} - -function setDownloadedVersion(version: string): void { - const dir = path.dirname(getMetaPath()); - if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync( - getMetaPath(), - JSON.stringify({ version, downloadedAt: new Date().toISOString() }, null, 2), - ); -} - -function isDownloadedFor(remoteVersion: string): boolean { - return getDownloadedVersion() === remoteVersion; -} - -/** Broadcast state to all renderer windows. */ -function sendUpdateStatus(state: UpdateState): void { - for (const win of BrowserWindow.getAllWindows()) { - win.webContents.send('updater:status', state); - } -} - -export function registerUpdaterHandlers(): void { - const portable = isPortableMode(); - - if (!app.isPackaged) { - autoUpdater.forceDevUpdateConfig = true; // for dev testing - } - - autoUpdater.logger = log; - autoUpdater.autoDownload = false; - autoUpdater.autoInstallOnAppQuit = false; - autoUpdater.allowPrerelease = false; - - try { - const cfg = readUpdateConfigFromPackageJson(); - autoUpdater.setFeedURL({ - provider: cfg.provider, - owner: cfg.owner, - repo: cfg.repo, - releaseType: cfg.releaseType ?? 'release', - } as any); - } catch (e) { - log.error('Updater config error:', e); - } - - autoUpdater.on('checking-for-update', () => sendUpdateStatus({ status: 'checking' })); - - autoUpdater.on('update-available', (info: UpdateInfo) => { - sendUpdateStatus({ status: isDownloadedFor(info.version) ? 'downloaded' : 'available', info }); - }); - - autoUpdater.on('update-not-available', (info: UpdateInfo) => { - sendUpdateStatus({ status: 'not-available', info }); - }); - - autoUpdater.on('download-progress', (progress) => { - sendUpdateStatus({ status: 'downloading', progress: Math.round(progress.percent) }); - }); - - autoUpdater.on('update-downloaded', (info: UpdateInfo) => { - setDownloadedVersion(info.version); - sendUpdateStatus({ status: 'downloaded', info }); - }); - - autoUpdater.on('error', (err: Error) => { - sendUpdateStatus({ status: 'error', error: err.message }); - }); - - ipcMain.handle('updater:isPortable', () => isPortableMode()); - - ipcMain.handle('updater:check', async (): Promise => { - try { - const result = await autoUpdater.checkForUpdates(); - const info = result?.updateInfo; - - if (!info) return { status: 'not-available' }; - - const available = autoUpdater.currentVersion.compare(info.version) < 0; - if (!available) return { status: 'not-available', info }; - - return { status: isDownloadedFor(info.version) ? 'downloaded' : 'available', info }; - } catch (e) { - const msg = e instanceof Error ? e.message : 'Unknown error'; - return { status: 'error', error: msg }; - } - }); - - ipcMain.handle('updater:download', async (): Promise => { - if (portable) { - sendUpdateStatus({ - status: 'unsupported', - error: 'Portable build does not support updater.', - }); - return false; - } - try { - await autoUpdater.downloadUpdate(); - return true; - } catch (e) { - log.error('Download failed:', e); - return false; - } - }); - - ipcMain.handle('updater:install', (): void => { - if (portable) { - sendUpdateStatus({ - status: 'unsupported', - error: 'Portable build does not support updater.', - }); - return; - } - autoUpdater.quitAndInstall(false, true); - }); - - ipcMain.handle('updater:getVersion', (): string => autoUpdater.currentVersion.version); -} diff --git a/frontend/electron/main/window.ts b/frontend/electron/main/window.ts deleted file mode 100644 index b655e90a..00000000 --- a/frontend/electron/main/window.ts +++ /dev/null @@ -1,251 +0,0 @@ -import { app, BrowserWindow, Menu, Tray, ipcMain } from 'electron'; -import { readFileSync } from 'fs'; -import path from 'path'; -import { translations } from '../../src/shared/locales'; - -let mainWindow: BrowserWindow | null = null; -let tray: Tray | null = null; -let isQuitting = false; -type MenuTarget = 'input' | 'process' | 'download' | 'setting' | 'about'; -type TrayLocale = 'vi' | 'en'; -type TrayLabels = { - createTask: string; - processing: string; - results: string; - settings: string; - about: string; - appTitle: string; - show: string; - hideToTray: string; - quit: string; -}; -const DEFAULT_TRAY_LOCALE: TrayLocale = 'vi'; -let trayLocale: TrayLocale | null = null; -let trayEventsBound = false; - -export const getMainWindow = () => mainWindow; - -export const setIsQuitting = (value: boolean) => { - isQuitting = value; -}; - -export const createMainWindow = (options: { - preloadPath: string; - getAssetPath: (...parts: string[]) => string; - isDev: boolean; - devUrl: string; - indexPath: string; -}) => { - mainWindow = new BrowserWindow({ - width: 1200, - height: 800, - minWidth: 800, - minHeight: 600, - icon: options.getAssetPath('images', 'app-icon.png'), - frame: false, - autoHideMenuBar: true, - webPreferences: { - preload: options.preloadPath, - contextIsolation: true, - nodeIntegration: false, - }, - }); - mainWindow.maximize(); - - if (options.isDev) { - mainWindow.loadURL(options.devUrl); - mainWindow.webContents.openDevTools(); - } else { - mainWindow.loadFile(options.indexPath); - } - - mainWindow.on('closed', () => { - mainWindow = null; - }); - - ensureTray(options.getAssetPath); -}; - -const normalizeTrayLocale = (value?: string | null): TrayLocale => { - return value === 'en' ? 'en' : 'vi'; -}; - -const loadTrayLocaleFromSettings = (): TrayLocale => { - try { - const settingsPath = path.join(app.getPath('userData'), 'app-settings.json'); - const data = readFileSync(settingsPath, 'utf-8'); - const parsed = JSON.parse(data) as { language?: string }; - return normalizeTrayLocale(parsed?.language); - } catch (_error) { - return DEFAULT_TRAY_LOCALE; - } -}; - -const resolveTrayLocale = (): TrayLocale => { - if (trayLocale) return trayLocale; - trayLocale = loadTrayLocaleFromSettings(); - return trayLocale; -}; - -const getTrayLabels = (locale: TrayLocale): TrayLabels => { - const dictionary = (translations[locale] ?? translations[DEFAULT_TRAY_LOCALE]) as Record< - string, - string - >; - const t = (key: string, fallback: string) => dictionary[key] ?? fallback; - return { - createTask: t('sideBar.createTask', 'Create Task'), - processing: t('sideBar.process', 'Processing'), - results: t('sideBar.result', 'Results'), - settings: t('sideBar.setting', 'Settings'), - about: t('sideBar.about', 'About'), - appTitle: t('app.title', 'Slide Generator'), - show: t('tray.show', 'Show'), - hideToTray: t('tray.hideToTray', 'Minimize to tray'), - quit: t('tray.quit', 'Quit'), - }; -}; - -const navigateTo = (menu: MenuTarget) => { - if (mainWindow?.isMinimized()) { - mainWindow.restore(); - } - mainWindow?.show(); - mainWindow?.focus(); - mainWindow?.webContents.send('app:navigate', menu); -}; - -const getWindowVisibilityState = () => { - if (!mainWindow) { - return { canShow: false, canHide: false }; - } - - const isMinimized = mainWindow.isMinimized(); - const isVisible = mainWindow.isVisible(); - return { - canShow: !isVisible || isMinimized, - canHide: isVisible && !isMinimized, - }; -}; - -const buildTrayMenu = (labels: TrayLabels) => { - const { canShow, canHide } = getWindowVisibilityState(); - return Menu.buildFromTemplate([ - { - label: labels.createTask, - click: () => navigateTo('input'), - }, - { - label: labels.processing, - click: () => navigateTo('process'), - }, - { - label: labels.results, - click: () => navigateTo('download'), - }, - { type: 'separator' }, - { - label: labels.settings, - click: () => navigateTo('setting'), - }, - { - label: labels.about, - click: () => navigateTo('about'), - }, - { type: 'separator' }, - { - label: canShow ? `${labels.show} ${labels.appTitle}` : labels.hideToTray, - enabled: canShow || canHide, - click: () => { - if (canShow) { - if (mainWindow?.isMinimized()) { - mainWindow.restore(); - } - mainWindow?.show(); - mainWindow?.focus(); - } else if (canHide) { - mainWindow?.hide(); - } - }, - }, - { type: 'separator' }, - { - label: labels.quit, - click: () => { - isQuitting = true; - mainWindow?.close(); - }, - }, - ]); -}; - -const refreshTrayMenu = () => { - if (!tray) return; - const locale = resolveTrayLocale(); - const labels = getTrayLabels(locale); - tray.setToolTip(labels.appTitle); - tray.setContextMenu(buildTrayMenu(labels)); -}; - -const attachTrayWindowEvents = () => { - if (!mainWindow || trayEventsBound) return; - trayEventsBound = true; - mainWindow.on('show', refreshTrayMenu); - mainWindow.on('hide', refreshTrayMenu); - mainWindow.on('minimize', refreshTrayMenu); - mainWindow.on('restore', refreshTrayMenu); -}; - -const ensureTray = (getAssetPath: (...parts: string[]) => string) => { - if (tray || !mainWindow) return; - - tray = new Tray(getAssetPath('images', 'app-icon.png')); - refreshTrayMenu(); - attachTrayWindowEvents(); - tray.on('click', () => { - mainWindow?.show(); - mainWindow?.focus(); - }); -}; - -export const registerWindowHandlers = (getAssetPath: (...parts: string[]) => string) => { - ipcMain.handle('window:control', async (_, action: string) => { - if (!mainWindow) return; - - switch (action) { - case 'minimize': - mainWindow.minimize(); - break; - case 'maximize': - if (mainWindow.isMaximized()) { - mainWindow.unmaximize(); - } else { - mainWindow.maximize(); - } - break; - case 'close': - if (isQuitting) { - mainWindow.close(); - } else { - mainWindow.close(); - } - break; - } - }); - - ipcMain.handle('window:hideToTray', async () => { - if (!mainWindow) return; - ensureTray(getAssetPath); - mainWindow.hide(); - }); - - ipcMain.handle('window:setProgress', async (_, value: number) => { - if (!mainWindow) return; - mainWindow.setProgressBar(value); - }); - - ipcMain.handle('tray:setLocale', async (_, value: string) => { - trayLocale = normalizeTrayLocale(value); - refreshTrayMenu(); - }); -}; diff --git a/frontend/electron/preload.ts b/frontend/electron/preload.ts deleted file mode 100644 index 467cb5a7..00000000 --- a/frontend/electron/preload.ts +++ /dev/null @@ -1,6 +0,0 @@ -const getAssetPath = (...p: string[]) => ipcRenderer.sendSync('assets:getPathSync', ...p); -import { contextBridge, ipcRenderer } from 'electron'; -import { createElectronAPI } from './preload/api'; - -contextBridge.exposeInMainWorld('electronAPI', createElectronAPI(ipcRenderer)); -contextBridge.exposeInMainWorld('getAssetPath', getAssetPath); diff --git a/frontend/electron/preload/api.ts b/frontend/electron/preload/api.ts deleted file mode 100644 index 82131197..00000000 --- a/frontend/electron/preload/api.ts +++ /dev/null @@ -1,88 +0,0 @@ -import type { IpcRenderer, IpcRendererEvent } from 'electron'; - -export type UpdateStatus = - | 'checking' - | 'available' - | 'not-available' - | 'downloading' - | 'downloaded' - | 'error' - | 'unsupported'; - -export interface UpdateState { - status: UpdateStatus; - info?: { version: string; releaseNotes?: string }; - progress?: number; - error?: string; -} - -export interface ElectronAPI { - openFile: (filters?: { name: string; extensions: string[] }[]) => Promise; - openMultipleFiles: ( - filters?: { name: string; extensions: string[] }[], - ) => Promise; - openFolder: () => Promise; - saveFile: (filters?: { name: string; extensions: string[] }[]) => Promise; - openUrl: (url: string) => Promise; - openPath: (path: string) => Promise; - readSettings: (filename: string) => Promise; - writeSettings: (filename: string, data: string) => Promise; - windowControl: (action: 'minimize' | 'maximize' | 'close') => Promise; - hideToTray: () => Promise; - setProgressBar: (value: number) => Promise; - restartBackend: () => Promise; - logRenderer: ( - level: 'debug' | 'info' | 'warn' | 'error', - message: string, - source?: string, - ) => void; - onNavigate: ( - handler: (menu: 'input' | 'process' | 'download' | 'setting' | 'about') => void, - ) => () => void; - setTrayLocale: (locale: 'vi' | 'en') => Promise; - // Updater APIs - checkForUpdates: () => Promise; - downloadUpdate: () => Promise; - installUpdate: () => void; - onUpdateStatus: (handler: (state: UpdateState) => void) => () => void; - isPortable: () => Promise; -} - -export const createElectronAPI = (ipcRenderer: IpcRenderer): ElectronAPI => { - return { - openFile: (filters) => ipcRenderer.invoke('dialog:openFile', filters), - openMultipleFiles: (filters) => ipcRenderer.invoke('dialog:openMultipleFiles', filters), - openFolder: () => ipcRenderer.invoke('dialog:openFolder'), - saveFile: (filters) => ipcRenderer.invoke('dialog:saveFile', filters), - openUrl: (url) => ipcRenderer.invoke('dialog:openUrl', url), - openPath: (path) => ipcRenderer.invoke('dialog:openPath', path), - readSettings: (filename) => ipcRenderer.invoke('settings:read', filename), - writeSettings: (filename, data) => ipcRenderer.invoke('settings:write', filename, data), - windowControl: (action) => ipcRenderer.invoke('window:control', action), - hideToTray: () => ipcRenderer.invoke('window:hideToTray'), - setProgressBar: (value) => ipcRenderer.invoke('window:setProgress', value), - restartBackend: () => ipcRenderer.invoke('backend:restart'), - logRenderer: (level, message, source) => - ipcRenderer.send('logs:renderer', { level, message, source }), - onNavigate: (handler) => { - const listener = (_: IpcRendererEvent, menu: string) => { - handler(menu as 'input' | 'process' | 'download' | 'setting' | 'about'); - }; - ipcRenderer.on('app:navigate', listener); - return () => ipcRenderer.removeListener('app:navigate', listener); - }, - setTrayLocale: (locale) => ipcRenderer.invoke('tray:setLocale', locale), - // Updater APIs - checkForUpdates: () => ipcRenderer.invoke('updater:check'), - downloadUpdate: () => ipcRenderer.invoke('updater:download'), - installUpdate: () => ipcRenderer.invoke('updater:install'), - onUpdateStatus: (handler) => { - const listener = (_: IpcRendererEvent, state: unknown) => { - handler(state as UpdateState); - }; - ipcRenderer.on('updater:status', listener); - return () => ipcRenderer.removeListener('updater:status', listener); - }, - isPortable: () => ipcRenderer.invoke('updater:isPortable'), - }; -}; diff --git a/frontend/index.html b/frontend/index.html deleted file mode 100644 index cff0990b..00000000 --- a/frontend/index.html +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - Slide Generator - - - - -
- - - diff --git a/frontend/package-lock.json b/frontend/package-lock.json deleted file mode 100644 index e2f195a8..00000000 --- a/frontend/package-lock.json +++ /dev/null @@ -1,8387 +0,0 @@ -{ - "name": "slide-generator", - "version": "1.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "slide-generator", - "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" - }, - "devDependencies": { - "@testing-library/jest-dom": "^6.9.1", - "@testing-library/react": "^16.3.1", - "@testing-library/user-event": "^14.6.1", - "@types/react": "^19.2.8", - "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^5.1.2", - "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", - "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" - } - }, - "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==", - "dev": true, - "license": "MIT" - }, - "node_modules/@adobe/css-tools": { - "version": "4.4.4", - "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", - "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", - "dev": true, - "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==", - "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" - } - }, - "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==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "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==", - "dev": true, - "license": "MIT", - "dependencies": { - "@asamuzakjp/nwsapi": "^2.3.9", - "bidi-js": "^1.0.3", - "css-tree": "^3.1.0", - "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.2.4" - } - }, - "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==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@asamuzakjp/nwsapi": { - "version": "2.3.9", - "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", - "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", - "dev": true, - "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==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "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==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "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==", - "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", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/generator": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", - "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.28.5", - "@babel/types": "^7.28.5", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "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==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.27.2", - "@babel/helper-validator-option": "^7.27.1", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "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==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" - }, - "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==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.28.3" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", - "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", - "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.4" - }, - "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==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.28.5" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-self": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", - "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-source": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", - "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/runtime": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", - "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "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==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/parser": "^7.27.2", - "@babel/types": "^7.27.1" - }, - "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==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.5", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "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==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "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==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "engines": { - "node": ">=18" - } - }, - "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==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" - } - }, - "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==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "dependencies": { - "@csstools/color-helpers": "^5.1.0", - "@csstools/css-calc": "^2.1.4" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" - } - }, - "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==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-tokenizer": "^3.0.4" - } - }, - "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==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "engines": { - "node": ">=18" - } - }, - "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==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@develar/schema-utils": { - "version": "2.6.5", - "resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz", - "integrity": "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.12.0", - "ajv-keywords": "^3.4.1" - }, - "engines": { - "node": ">= 8.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/@electron/asar": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz", - "integrity": "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==", - "dev": true, - "license": "MIT", - "dependencies": { - "commander": "^5.0.0", - "glob": "^7.1.6", - "minimatch": "^3.0.4" - }, - "bin": { - "asar": "bin/asar.js" - }, - "engines": { - "node": ">=10.12.0" - } - }, - "node_modules/@electron/asar/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/@electron/fuses": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@electron/fuses/-/fuses-1.8.0.tgz", - "integrity": "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.1.1", - "fs-extra": "^9.0.1", - "minimist": "^1.2.5" - }, - "bin": { - "electron-fuses": "dist/bin.js" - } - }, - "node_modules/@electron/fuses/node_modules/fs-extra": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", - "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "at-least-node": "^1.0.0", - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@electron/fuses/node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/@electron/fuses/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/@electron/get": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz", - "integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==", - "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": ">=12" - }, - "optionalDependencies": { - "global-agent": "^3.0.0" - } - }, - "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/@electron/notarize": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-2.5.0.tgz", - "integrity": "sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.1.1", - "fs-extra": "^9.0.1", - "promise-retry": "^2.0.1" - }, - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/@electron/notarize/node_modules/fs-extra": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", - "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "at-least-node": "^1.0.0", - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@electron/notarize/node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/@electron/notarize/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/@electron/osx-sign": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@electron/osx-sign/-/osx-sign-1.3.3.tgz", - "integrity": "sha512-KZ8mhXvWv2rIEgMbWZ4y33bDHyUKMXnx4M0sTyPNK/vcB81ImdeY9Ggdqy0SWbMDgmbqyQ+phgejh6V3R2QuSg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "compare-version": "^0.1.2", - "debug": "^4.3.4", - "fs-extra": "^10.0.0", - "isbinaryfile": "^4.0.8", - "minimist": "^1.2.6", - "plist": "^3.0.5" - }, - "bin": { - "electron-osx-flat": "bin/electron-osx-flat.js", - "electron-osx-sign": "bin/electron-osx-sign.js" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/@electron/osx-sign/node_modules/isbinaryfile": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", - "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/gjtorikian/" - } - }, - "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==", - "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", - "graceful-fs": "^4.2.11", - "node-abi": "^4.2.0", - "node-api-version": "^0.2.1", - "node-gyp": "^11.2.0", - "ora": "^5.1.0", - "read-binary-file-arch": "^1.0.6", - "semver": "^7.3.5", - "tar": "^6.0.5", - "yargs": "^17.0.1" - }, - "bin": { - "electron-rebuild": "lib/cli.js" - }, - "engines": { - "node": ">=22.12.0" - } - }, - "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==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@electron/universal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-2.0.3.tgz", - "integrity": "sha512-Wn9sPYIVFRFl5HmwMJkARCCf7rqK/EurkfQ/rJZ14mHP3iYTjZSIOSVonEAnhWeAXwtw7zOekGRlc6yTtZ0t+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@electron/asar": "^3.3.1", - "@malept/cross-spawn-promise": "^2.0.0", - "debug": "^4.3.1", - "dir-compare": "^4.2.0", - "fs-extra": "^11.1.1", - "minimatch": "^9.0.3", - "plist": "^3.1.0" - }, - "engines": { - "node": ">=16.4" - } - }, - "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", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@electron/universal/node_modules/fs-extra": { - "version": "11.3.3", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.3.tgz", - "integrity": "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=14.14" - } - }, - "node_modules/@electron/universal/node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/@electron/universal/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@electron/universal/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/@electron/windows-sign": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@electron/windows-sign/-/windows-sign-1.2.2.tgz", - "integrity": "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==", - "dev": true, - "license": "BSD-2-Clause", - "optional": true, - "peer": true, - "dependencies": { - "cross-dirname": "^0.1.0", - "debug": "^4.3.4", - "fs-extra": "^11.1.1", - "minimist": "^1.2.8", - "postject": "^1.0.0-alpha.6" - }, - "bin": { - "electron-windows-sign": "bin/electron-windows-sign.js" - }, - "engines": { - "node": ">=14.14" - } - }, - "node_modules/@electron/windows-sign/node_modules/fs-extra": { - "version": "11.3.3", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.3.tgz", - "integrity": "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=14.14" - } - }, - "node_modules/@electron/windows-sign/node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/@electron/windows-sign/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", - "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", - "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", - "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", - "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", - "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", - "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", - "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", - "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", - "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", - "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", - "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", - "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", - "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", - "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", - "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", - "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", - "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", - "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", - "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", - "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", - "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", - "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", - "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", - "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", - "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", - "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "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==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "@exodus/crypto": "^1.0.0-rc.4" - }, - "peerDependenciesMeta": { - "@exodus/crypto": { - "optional": true - } - } - }, - "node_modules/@hapi/address": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz", - "integrity": "sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@hapi/hoek": "^11.0.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@hapi/formula": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@hapi/formula/-/formula-3.0.2.tgz", - "integrity": "sha512-hY5YPNXzw1He7s0iqkRQi+uMGh383CGdyyIGYtB+W5N3KHPXoqychklvHhKCC9M3Xtv0OCs/IHw+r4dcHtBYWw==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/@hapi/hoek": { - "version": "11.0.7", - "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-11.0.7.tgz", - "integrity": "sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/@hapi/pinpoint": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@hapi/pinpoint/-/pinpoint-2.0.1.tgz", - "integrity": "sha512-EKQmr16tM8s16vTT3cA5L0kZZcTMU5DUOZTuvpnY738m+jyP3JIUj+Mm1xc1rsLkGBQ/gVnfKYPwOmPg1tUR4Q==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/@hapi/tlds": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@hapi/tlds/-/tlds-1.1.4.tgz", - "integrity": "sha512-Fq+20dxsxLaUn5jSSWrdtSRcIUba2JquuorF9UW1wIJS5cSUwxIsO2GIhaWynPRflvxSzFN+gxKte2HEW1OuoA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@hapi/topo": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-6.0.2.tgz", - "integrity": "sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@hapi/hoek": "^11.0.2" - } - }, - "node_modules/@inquirer/ansi": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", - "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@inquirer/confirm": { - "version": "5.1.21", - "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz", - "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/core": "^10.3.2", - "@inquirer/type": "^3.0.10" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/core": { - "version": "10.3.2", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.2.tgz", - "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^1.0.2", - "@inquirer/figures": "^1.0.15", - "@inquirer/type": "^3.0.10", - "cli-width": "^4.1.0", - "mute-stream": "^2.0.0", - "signal-exit": "^4.1.0", - "wrap-ansi": "^6.2.0", - "yoctocolors-cjs": "^2.1.3" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/core/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@inquirer/core/node_modules/wrap-ansi": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", - "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@inquirer/figures": { - "version": "1.0.15", - "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", - "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@inquirer/type": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.10.tgz", - "integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "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", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@isaacs/cliui/node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/@isaacs/fs-minipass": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", - "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.4" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@malept/cross-spawn-promise": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz", - "integrity": "sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/malept" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/subscription/pkg/npm-.malept-cross-spawn-promise?utm_medium=referral&utm_source=npm_fund" - } - ], - "license": "Apache-2.0", - "dependencies": { - "cross-spawn": "^7.0.1" - }, - "engines": { - "node": ">= 12.13.0" - } - }, - "node_modules/@malept/flatpak-bundler": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@malept/flatpak-bundler/-/flatpak-bundler-0.4.0.tgz", - "integrity": "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.1.1", - "fs-extra": "^9.0.0", - "lodash": "^4.17.15", - "tmp-promise": "^3.0.2" - }, - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/@malept/flatpak-bundler/node_modules/fs-extra": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", - "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "at-least-node": "^1.0.0", - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@malept/flatpak-bundler/node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/@malept/flatpak-bundler/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "engines": { - "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==", - "dev": true, - "license": "MIT", - "dependencies": { - "@open-draft/deferred-promise": "^2.2.0", - "@open-draft/logger": "^0.3.0", - "@open-draft/until": "^2.0.0", - "is-node-process": "^1.2.0", - "outvariant": "^1.4.3", - "strict-event-emitter": "^0.5.1" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@npmcli/agent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-3.0.0.tgz", - "integrity": "sha512-S79NdEgDQd/NGCay6TCoVzXSj74skRZIKJcpJjC5lOq34SZzyI6MqtiiWoiVWoVrTcGjNeC4ipbh1VIHlpfF5Q==", - "dev": true, - "license": "ISC", - "dependencies": { - "agent-base": "^7.1.0", - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.1", - "lru-cache": "^10.0.1", - "socks-proxy-agent": "^8.0.3" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/@npmcli/agent/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/@npmcli/fs": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-4.0.0.tgz", - "integrity": "sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q==", - "dev": true, - "license": "ISC", - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "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==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@open-draft/deferred-promise": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", - "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@open-draft/logger": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", - "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-node-process": "^1.2.0", - "outvariant": "^1.4.0" - } - }, - "node_modules/@open-draft/until": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", - "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } - }, - "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==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz", - "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz", - "integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz", - "integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz", - "integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz", - "integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz", - "integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz", - "integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz", - "integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz", - "integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz", - "integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz", - "integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz", - "integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz", - "integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz", - "integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz", - "integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz", - "integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz", - "integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz", - "integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz", - "integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz", - "integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz", - "integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz", - "integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@sindresorhus/is": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", - "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/is?sponsor=1" - } - }, - "node_modules/@standard-schema/spec": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", - "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@szmarczak/http-timer": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", - "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", - "dev": true, - "license": "MIT", - "dependencies": { - "defer-to-connect": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@testing-library/dom": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", - "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.10.4", - "@babel/runtime": "^7.12.5", - "@types/aria-query": "^5.0.1", - "aria-query": "5.3.0", - "dom-accessibility-api": "^0.5.9", - "lz-string": "^1.5.0", - "picocolors": "1.1.1", - "pretty-format": "^27.0.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@testing-library/jest-dom": { - "version": "6.9.1", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", - "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@adobe/css-tools": "^4.4.0", - "aria-query": "^5.0.0", - "css.escape": "^1.5.1", - "dom-accessibility-api": "^0.6.3", - "picocolors": "^1.1.1", - "redent": "^3.0.0" - }, - "engines": { - "node": ">=14", - "npm": ">=6", - "yarn": ">=1" - } - }, - "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", - "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", - "dev": true, - "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==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.12.5" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@testing-library/dom": "^10.0.0", - "@types/react": "^18.0.0 || ^19.0.0", - "@types/react-dom": "^18.0.0 || ^19.0.0", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@testing-library/user-event": { - "version": "14.6.1", - "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", - "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12", - "npm": ">=6" - }, - "peerDependencies": { - "@testing-library/dom": ">=7.21.4" - } - }, - "node_modules/@types/aria-query": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", - "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } - }, - "node_modules/@types/babel__generator": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", - "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__traverse": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", - "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.28.2" - } - }, - "node_modules/@types/cacheable-request": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", - "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/http-cache-semantics": "*", - "@types/keyv": "^3.1.4", - "@types/node": "*", - "@types/responselike": "^1.0.0" - } - }, - "node_modules/@types/chai": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/deep-eql": "*", - "assertion-error": "^2.0.1" - } - }, - "node_modules/@types/debug": { - "version": "4.1.12", - "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", - "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/ms": "*" - } - }, - "node_modules/@types/deep-eql": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/fs-extra": { - "version": "9.0.13", - "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz", - "integrity": "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/http-cache-semantics": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz", - "integrity": "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/keyv": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", - "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/ms": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", - "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "24.10.9", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.9.tgz", - "integrity": "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~7.16.0" - } - }, - "node_modules/@types/plist": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@types/plist/-/plist-3.0.5.tgz", - "integrity": "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@types/node": "*", - "xmlbuilder": ">=11.0.1" - } - }, - "node_modules/@types/react": { - "version": "19.2.8", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.8.tgz", - "integrity": "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==", - "dev": true, - "license": "MIT", - "dependencies": { - "csstype": "^3.2.2" - } - }, - "node_modules/@types/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "^19.2.0" - } - }, - "node_modules/@types/responselike": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", - "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/statuses": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz", - "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/verror": { - "version": "1.10.11", - "resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.11.tgz", - "integrity": "sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg==", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/@types/yauzl": { - "version": "2.10.3", - "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", - "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@types/node": "*" - } - }, - "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==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.28.5", - "@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", - "@types/babel__core": "^7.20.5", - "react-refresh": "^0.18.0" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" - } - }, - "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==", - "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", - "chai": "^6.2.1", - "tinyrainbow": "^3.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "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==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "4.0.17", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0-0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "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==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^3.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "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==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "4.0.17", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.17.tgz", - "integrity": "sha512-npPelD7oyL+YQM2gbIYvlavlMVWUfNNGZPcu0aEUQXt7FXTuqhmgiYupPnAanhKvyP6Srs2pIbWo30K0RbDtRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.0.17", - "magic-string": "^0.30.21", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/spy": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.17.tgz", - "integrity": "sha512-I1bQo8QaP6tZlTomQNWKJE6ym4SHf3oLS7ceNjozxxgzavRAgZDc06T7kD8gb9bXKEgcLNt00Z+kZO6KaJ62Ew==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "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==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.0.17", - "tinyrainbow": "^3.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@xmldom/xmldom": { - "version": "0.8.11", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz", - "integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/7zip-bin": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz", - "integrity": "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==", - "dev": true, - "license": "MIT" - }, - "node_modules/abbrev": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz", - "integrity": "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==", - "dev": true, - "license": "ISC", - "engines": { - "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", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-keywords": { - "version": "3.5.2", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", - "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "ajv": "^6.9.1" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/app-builder-bin": { - "version": "5.0.0-alpha.12", - "resolved": "https://registry.npmjs.org/app-builder-bin/-/app-builder-bin-5.0.0-alpha.12.tgz", - "integrity": "sha512-j87o0j6LqPL3QRr8yid6c+Tt5gC7xNfYo6uQIQkorAC6MpeayVMZrEDzKmJJ/Hlv7EnOQpaRm53k6ktDYZyB6w==", - "dev": true, - "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==", - "dev": true, - "license": "MIT", - "dependencies": { - "@develar/schema-utils": "~2.6.5", - "@electron/asar": "3.4.1", - "@electron/fuses": "^1.8.0", - "@electron/notarize": "2.5.0", - "@electron/osx-sign": "1.3.3", - "@electron/rebuild": "4.0.1", - "@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-runtime": "9.5.1", - "chromium-pickle-js": "^0.2.0", - "ci-info": "4.3.1", - "debug": "^4.3.4", - "dotenv": "^16.4.5", - "dotenv-expand": "^11.0.6", - "ejs": "^3.1.8", - "electron-publish": "26.3.4", - "fs-extra": "^10.1.0", - "hosted-git-info": "^4.1.0", - "isbinaryfile": "^5.0.0", - "jiti": "^2.4.2", - "js-yaml": "^4.1.0", - "json5": "^2.2.3", - "lazy-val": "^1.0.5", - "minimatch": "^10.0.3", - "plist": "3.1.0", - "resedit": "^1.7.0", - "semver": "~7.7.3", - "tar": "^6.1.12", - "temp-file": "^3.4.0", - "tiny-async-pool": "1.3.0", - "which": "^5.0.0" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "dmg-builder": "26.4.0", - "electron-builder-squirrel-windows": "26.4.0" - } - }, - "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==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "license": "Python-2.0" - }, - "node_modules/aria-query": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", - "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "dequal": "^2.0.3" - } - }, - "node_modules/assert-plus": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", - "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=0.8" - } - }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/astral-regex": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", - "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/async": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", - "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", - "dev": true, - "license": "MIT" - }, - "node_modules/async-exit-hook": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/async-exit-hook/-/async-exit-hook-2.0.1.tgz", - "integrity": "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/at-least-node": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", - "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">= 4.0.0" - } - }, - "node_modules/axios": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", - "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", - "dev": true, - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", - "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==", - "dev": true, - "license": "MIT" - }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "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==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.js" - } - }, - "node_modules/bidi-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", - "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", - "dev": true, - "license": "MIT", - "dependencies": { - "require-from-string": "^2.0.2" - } - }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, - "node_modules/boolean": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", - "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", - "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", - "dev": true, - "license": "MIT", - "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==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "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==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "baseline-browser-mapping": "^2.8.25", - "caniuse-lite": "^1.0.30001754", - "electron-to-chromium": "^1.5.249", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.1.4" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, - "node_modules/buffer-crc32": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", - "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, - "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==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/debug": "^4.1.6", - "7zip-bin": "~5.2.0", - "app-builder-bin": "5.0.0-alpha.12", - "builder-util-runtime": "9.5.1", - "chalk": "^4.1.2", - "cross-spawn": "^7.0.6", - "debug": "^4.3.4", - "fs-extra": "^10.1.0", - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.0", - "js-yaml": "^4.1.0", - "sanitize-filename": "^1.6.3", - "source-map-support": "^0.5.19", - "stat-mode": "^1.0.0", - "temp-file": "^3.4.0", - "tiny-async-pool": "1.3.0" - } - }, - "node_modules/builder-util-runtime": { - "version": "9.5.1", - "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.5.1.tgz", - "integrity": "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ==", - "license": "MIT", - "dependencies": { - "debug": "^4.3.4", - "sax": "^1.2.4" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/cacache": { - "version": "19.0.1", - "resolved": "https://registry.npmjs.org/cacache/-/cacache-19.0.1.tgz", - "integrity": "sha512-hdsUxulXCi5STId78vRVYEtDAjq99ICAUktLTeTYsLoTE6Z8dS0c8pWNCxwdrk9YfJeobDZc2Y186hD/5ZQgFQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "@npmcli/fs": "^4.0.0", - "fs-minipass": "^3.0.0", - "glob": "^10.2.2", - "lru-cache": "^10.0.1", - "minipass": "^7.0.3", - "minipass-collect": "^2.0.1", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "p-map": "^7.0.2", - "ssri": "^12.0.0", - "tar": "^7.4.3", - "unique-filename": "^4.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/cacache/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "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==", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/cacache/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/cacache/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "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", - "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.6.0" - } - }, - "node_modules/cacheable-request": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", - "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", - "dev": true, - "license": "MIT", - "dependencies": { - "clone-response": "^1.0.2", - "get-stream": "^5.1.0", - "http-cache-semantics": "^4.0.0", - "keyv": "^4.0.0", - "lowercase-keys": "^2.0.0", - "normalize-url": "^6.0.1", - "responselike": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "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==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/chalk/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/chownr": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", - "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/chromium-pickle-js": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/chromium-pickle-js/-/chromium-pickle-js-0.2.0.tgz", - "integrity": "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==", - "dev": true, - "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==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/cli-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", - "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", - "dev": true, - "license": "MIT", - "dependencies": { - "restore-cursor": "^3.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cli-spinners": { - "version": "2.9.2", - "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", - "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-truncate": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz", - "integrity": "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "slice-ansi": "^3.0.0", - "string-width": "^4.2.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-width": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", - "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">= 12" - } - }, - "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/clone": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", - "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8" - } - }, - "node_modules/clone-response": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", - "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-response": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/commander": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", - "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/compare-version": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/compare-version/-/compare-version-0.1.2.tgz", - "integrity": "sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, - "node_modules/concurrently": { - "version": "9.2.1", - "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", - "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "4.1.2", - "rxjs": "7.8.2", - "shell-quote": "1.8.3", - "supports-color": "8.1.1", - "tree-kill": "1.2.2", - "yargs": "17.7.2" - }, - "bin": { - "conc": "dist/bin/concurrently.js", - "concurrently": "dist/bin/concurrently.js" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" - } - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/cookie": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", - "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/core-util-is": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", - "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/crc": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/crc/-/crc-3.8.0.tgz", - "integrity": "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "buffer": "^5.1.0" - } - }, - "node_modules/cross-dirname": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz", - "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/cross-spawn/node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" - }, - "node_modules/cross-spawn/node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/css-tree": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", - "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "mdn-data": "2.12.2", - "source-map-js": "^1.0.1" - }, - "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" - } - }, - "node_modules/css.escape": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", - "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", - "dev": true, - "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==", - "dev": true, - "license": "MIT", - "dependencies": { - "@asamuzakjp/css-color": "^4.1.1", - "@csstools/css-syntax-patches-for-csstree": "^1.0.21", - "css-tree": "^3.1.0", - "lru-cache": "^11.2.4" - }, - "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==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, - "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==", - "dev": true, - "license": "MIT", - "dependencies": { - "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^15.0.0" - }, - "engines": { - "node": ">=20" - } - }, - "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, - "license": "MIT", - "dependencies": { - "punycode": "^2.3.1" - }, - "engines": { - "node": ">=20" - } - }, - "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==", - "dev": true, - "license": "MIT" - }, - "node_modules/decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-response": "^3.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/decompress-response/node_modules/mimic-response": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/defaults": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", - "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "clone": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/defer-to-connect": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", - "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/define-properties": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", - "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "define-data-property": "^1.0.1", - "has-property-descriptors": "^1.0.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/detect-node": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", - "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/dir-compare": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz", - "integrity": "sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "minimatch": "^3.0.5", - "p-limit": "^3.1.0 " - } - }, - "node_modules/dir-compare/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "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==", - "dev": true, - "license": "MIT", - "dependencies": { - "app-builder-lib": "26.4.0", - "builder-util": "26.3.4", - "fs-extra": "^10.1.0", - "iconv-lite": "^0.6.2", - "js-yaml": "^4.1.0" - }, - "optionalDependencies": { - "dmg-license": "^1.0.11" - } - }, - "node_modules/dmg-license": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/dmg-license/-/dmg-license-1.0.11.tgz", - "integrity": "sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q==", - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "dependencies": { - "@types/plist": "^3.0.1", - "@types/verror": "^1.10.3", - "ajv": "^6.10.0", - "crc": "^3.8.0", - "iconv-corefoundation": "^1.1.7", - "plist": "^3.0.4", - "smart-buffer": "^4.0.2", - "verror": "^1.10.0" - }, - "bin": { - "dmg-license": "bin/dmg-license.js" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/dom-accessibility-api": { - "version": "0.5.16", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", - "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/dotenv": { - "version": "16.6.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", - "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, - "node_modules/dotenv-expand": { - "version": "11.0.7", - "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-11.0.7.tgz", - "integrity": "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "dotenv": "^16.4.5" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true, - "license": "MIT" - }, - "node_modules/ejs": { - "version": "3.1.10", - "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", - "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "jake": "^10.8.5" - }, - "bin": { - "ejs": "bin/cli.js" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "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==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "@electron/get": "^2.0.0", - "@types/node": "^24.9.0", - "extract-zip": "^2.0.1" - }, - "bin": { - "electron": "cli.js" - }, - "engines": { - "node": ">= 12.20.55" - } - }, - "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==", - "dev": true, - "license": "MIT", - "dependencies": { - "app-builder-lib": "26.4.0", - "builder-util": "26.3.4", - "builder-util-runtime": "9.5.1", - "chalk": "^4.1.2", - "ci-info": "^4.2.0", - "dmg-builder": "26.4.0", - "fs-extra": "^10.1.0", - "lazy-val": "^1.0.5", - "simple-update-notifier": "2.0.0", - "yargs": "^17.6.2" - }, - "bin": { - "electron-builder": "cli.js", - "install-app-deps": "install-app-deps.js" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "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==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "app-builder-lib": "26.4.0", - "builder-util": "26.3.4", - "electron-winstaller": "5.4.0" - } - }, - "node_modules/electron-log": { - "version": "5.4.3", - "resolved": "https://registry.npmjs.org/electron-log/-/electron-log-5.4.3.tgz", - "integrity": "sha512-sOUsM3LjZdugatazSQ/XTyNcw8dfvH1SYhXWiJyfYodAAKOZdHs0txPiLDXFzOZbhXgAgshQkshH2ccq0feyLQ==", - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "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==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/fs-extra": "^9.0.11", - "builder-util": "26.3.4", - "builder-util-runtime": "9.5.1", - "chalk": "^4.1.2", - "form-data": "^4.0.0", - "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==", - "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==", - "license": "MIT", - "dependencies": { - "builder-util-runtime": "9.5.1", - "fs-extra": "^10.1.0", - "js-yaml": "^4.1.0", - "lazy-val": "^1.0.5", - "lodash.escaperegexp": "^4.1.2", - "lodash.isequal": "^4.5.0", - "semver": "~7.7.3", - "tiny-typed-emitter": "^2.1.0" - } - }, - "node_modules/electron-updater/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/electron-winstaller": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/electron-winstaller/-/electron-winstaller-5.4.0.tgz", - "integrity": "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@electron/asar": "^3.2.1", - "debug": "^4.1.1", - "fs-extra": "^7.0.1", - "lodash": "^4.17.21", - "temp": "^0.9.0" - }, - "engines": { - "node": ">=8.0.0" - }, - "optionalDependencies": { - "@electron/windows-sign": "^1.1.2" - } - }, - "node_modules/electron-winstaller/node_modules/fs-extra": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", - "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "graceful-fs": "^4.1.2", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - }, - "engines": { - "node": ">=6 <7 || >=8" - } - }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/encoding": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", - "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "license": "MIT", - "optional": true, - "dependencies": { - "iconv-lite": "^0.6.2" - } - }, - "node_modules/end-of-stream": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", - "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "dev": true, - "license": "MIT", - "dependencies": { - "once": "^1.4.0" - } - }, - "node_modules/entities": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/env-paths": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", - "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/err-code": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", - "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", - "dev": true, - "license": "MIT" - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", - "dev": true, - "license": "MIT" - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es6-error": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", - "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/esbuild": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", - "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.2", - "@esbuild/android-arm": "0.27.2", - "@esbuild/android-arm64": "0.27.2", - "@esbuild/android-x64": "0.27.2", - "@esbuild/darwin-arm64": "0.27.2", - "@esbuild/darwin-x64": "0.27.2", - "@esbuild/freebsd-arm64": "0.27.2", - "@esbuild/freebsd-x64": "0.27.2", - "@esbuild/linux-arm": "0.27.2", - "@esbuild/linux-arm64": "0.27.2", - "@esbuild/linux-ia32": "0.27.2", - "@esbuild/linux-loong64": "0.27.2", - "@esbuild/linux-mips64el": "0.27.2", - "@esbuild/linux-ppc64": "0.27.2", - "@esbuild/linux-riscv64": "0.27.2", - "@esbuild/linux-s390x": "0.27.2", - "@esbuild/linux-x64": "0.27.2", - "@esbuild/netbsd-arm64": "0.27.2", - "@esbuild/netbsd-x64": "0.27.2", - "@esbuild/openbsd-arm64": "0.27.2", - "@esbuild/openbsd-x64": "0.27.2", - "@esbuild/openharmony-arm64": "0.27.2", - "@esbuild/sunos-x64": "0.27.2", - "@esbuild/win32-arm64": "0.27.2", - "@esbuild/win32-ia32": "0.27.2", - "@esbuild/win32-x64": "0.27.2" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@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", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/exponential-backoff": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", - "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/extract-zip": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", - "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "debug": "^4.1.1", - "get-stream": "^5.1.0", - "yauzl": "^2.10.0" - }, - "bin": { - "extract-zip": "cli.js" - }, - "engines": { - "node": ">= 10.17.0" - }, - "optionalDependencies": { - "@types/yauzl": "^2.9.1" - } - }, - "node_modules/extsprintf": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz", - "integrity": "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==", - "dev": true, - "engines": [ - "node >=0.6.0" - ], - "license": "MIT", - "optional": true - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fd-slicer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", - "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "pend": "~1.2.0" - } - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "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", - "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "minimatch": "^5.0.1" - } - }, - "node_modules/filelist/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/filelist/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/foreground-child/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "dev": true, - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/fs-extra/node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/fs-extra/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/fs-minipass": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.3.tgz", - "integrity": "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.3" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true, - "license": "ISC" - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/get-stream": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", - "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", - "dev": true, - "license": "MIT", - "dependencies": { - "pump": "^3.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/glob": { - "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", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/glob/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/global-agent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", - "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", - "dev": true, - "license": "BSD-3-Clause", - "optional": true, - "dependencies": { - "boolean": "^3.0.1", - "es6-error": "^4.1.1", - "matcher": "^3.0.0", - "roarr": "^2.15.3", - "semver": "^7.3.2", - "serialize-error": "^7.0.1" - }, - "engines": { - "node": ">=10.0" - } - }, - "node_modules/global-agent/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, - "license": "ISC", - "optional": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/globalthis": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", - "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "define-properties": "^1.2.1", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/got": { - "version": "11.8.6", - "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", - "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sindresorhus/is": "^4.0.0", - "@szmarczak/http-timer": "^4.0.5", - "@types/cacheable-request": "^6.0.1", - "@types/responselike": "^1.0.0", - "cacheable-lookup": "^5.0.3", - "cacheable-request": "^7.0.2", - "decompress-response": "^6.0.0", - "http2-wrapper": "^1.0.0-beta.5.2", - "lowercase-keys": "^2.0.0", - "p-cancelable": "^2.0.0", - "responselike": "^2.0.0" - }, - "engines": { - "node": ">=10.19.0" - }, - "funding": { - "url": "https://github.com/sindresorhus/got?sponsor=1" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "license": "ISC" - }, - "node_modules/graphql": { - "version": "16.12.0", - "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.12.0.tgz", - "integrity": "sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" - } - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "es-define-property": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/headers-polyfill": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-4.0.3.tgz", - "integrity": "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/hosted-git-info": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", - "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", - "dev": true, - "license": "ISC", - "dependencies": { - "lru-cache": "^6.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/hosted-git-info/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/hosted-git-info/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/html-encoding-sniffer": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", - "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@exodus/bytes": "^1.6.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/http-cache-semantics": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", - "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", - "dev": true, - "license": "BSD-2-Clause" - }, - "node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/http2-wrapper": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", - "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "quick-lru": "^5.1.1", - "resolve-alpn": "^1.0.0" - }, - "engines": { - "node": ">=10.19.0" - } - }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/iconv-corefoundation": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz", - "integrity": "sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ==", - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "dependencies": { - "cli-truncate": "^2.1.0", - "node-addon-api": "^1.6.3" - }, - "engines": { - "node": "^8.11.2 || >=10" - } - }, - "node_modules/iconv-lite": { - "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, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/indent-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", - "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, - "license": "ISC", - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-interactive": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", - "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-node-process": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", - "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", - "dev": true, - "license": "MIT" - }, - "node_modules/is-potential-custom-element-name": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", - "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/is-unicode-supported": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", - "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/isbinaryfile": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.7.tgz", - "integrity": "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 18.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/gjtorikian/" - } - }, - "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==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=16" - } - }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, - "node_modules/jake": { - "version": "10.9.4", - "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", - "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "async": "^3.2.6", - "filelist": "^1.0.4", - "picocolors": "^1.1.1" - }, - "bin": { - "jake": "bin/cli.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/jiti": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", - "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", - "dev": true, - "license": "MIT", - "bin": { - "jiti": "lib/jiti-cli.mjs" - } - }, - "node_modules/joi": { - "version": "18.0.2", - "resolved": "https://registry.npmjs.org/joi/-/joi-18.0.2.tgz", - "integrity": "sha512-RuCOQMIt78LWnktPoeBL0GErkNaJPTBGcYuyaBvUOQSpcpcLfWrHPPihYdOGbV5pam9VTWbeoF7TsGiHugcjGA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@hapi/address": "^5.1.1", - "@hapi/formula": "^3.0.2", - "@hapi/hoek": "^11.0.7", - "@hapi/pinpoint": "^2.0.1", - "@hapi/tlds": "^1.1.1", - "@hapi/topo": "^6.0.2", - "@standard-schema/spec": "^1.0.0" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/jsdom": { - "version": "27.4.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.4.0.tgz", - "integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==", - "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", - "decimal.js": "^10.6.0", - "html-encoding-sniffer": "^6.0.0", - "http-proxy-agent": "^7.0.2", - "https-proxy-agent": "^7.0.6", - "is-potential-custom-element-name": "^1.0.1", - "parse5": "^8.0.0", - "saxes": "^6.0.0", - "symbol-tree": "^3.2.4", - "tough-cookie": "^6.0.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", - "xml-name-validator": "^5.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "canvas": "^3.0.0" - }, - "peerDependenciesMeta": { - "canvas": { - "optional": true - } - } - }, - "node_modules/jsdom/node_modules/tough-cookie": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", - "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "tldts": "^7.0.5" - }, - "engines": { - "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", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-stringify-safe": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", - "dev": true, - "license": "ISC", - "optional": true - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/jsonfile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", - "dev": true, - "license": "MIT", - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" - } - }, - "node_modules/lazy-val": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", - "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", - "license": "MIT" - }, - "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.escaperegexp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", - "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", - "license": "MIT" - }, - "node_modules/lodash.isequal": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", - "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", - "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", - "license": "MIT" - }, - "node_modules/log-symbols": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", - "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.1.0", - "is-unicode-supported": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lowercase-keys": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", - "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/lz-string": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", - "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", - "dev": true, - "license": "MIT", - "peer": true, - "bin": { - "lz-string": "bin/bin.js" - } - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/make-fetch-happen": { - "version": "14.0.3", - "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-14.0.3.tgz", - "integrity": "sha512-QMjGbFTP0blj97EeidG5hk/QhKQ3T4ICckQGLgz38QF7Vgbk6e6FTARN8KhKxyBbWn8R0HU+bnw8aSoFPD4qtQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "@npmcli/agent": "^3.0.0", - "cacache": "^19.0.1", - "http-cache-semantics": "^4.1.1", - "minipass": "^7.0.2", - "minipass-fetch": "^4.0.0", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "negotiator": "^1.0.0", - "proc-log": "^5.0.0", - "promise-retry": "^2.0.1", - "ssri": "^12.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/matcher": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", - "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "escape-string-regexp": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/mdn-data": { - "version": "2.12.2", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", - "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", - "dev": true, - "license": "CC0-1.0" - }, - "node_modules/mime": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", - "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", - "dev": true, - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/mimic-response": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", - "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/min-indent": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", - "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/minimatch": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", - "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/brace-expansion": "^5.0.0" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/minipass-collect": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-2.0.1.tgz", - "integrity": "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.3" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/minipass-fetch": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-4.0.1.tgz", - "integrity": "sha512-j7U11C5HXigVuutxebFadoYBbd7VSdZWggSe64NVdvWNBqGAiXPL2QVCehjmw7lY1oF9gOllYbORh+hiNgfPgQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "minipass": "^7.0.3", - "minipass-sized": "^1.0.3", - "minizlib": "^3.0.1" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - }, - "optionalDependencies": { - "encoding": "^0.1.13" - } - }, - "node_modules/minipass-flush": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", - "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/minipass-flush/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", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-flush/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/minipass-pipeline": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", - "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-pipeline/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", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-pipeline/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/minipass-sized": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", - "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-sized/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", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-sized/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/minizlib": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", - "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "minipass": "^7.1.2" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "dev": true, - "license": "MIT", - "bin": { - "mkdirp": "bin/cmd.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "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==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "@inquirer/confirm": "^5.0.0", - "@mswjs/interceptors": "^0.40.0", - "@open-draft/deferred-promise": "^2.2.0", - "@types/statuses": "^2.0.6", - "cookie": "^1.0.2", - "graphql": "^16.12.0", - "headers-polyfill": "^4.0.2", - "is-node-process": "^1.2.0", - "outvariant": "^1.4.3", - "path-to-regexp": "^6.3.0", - "picocolors": "^1.1.1", - "rettime": "^0.7.0", - "statuses": "^2.0.2", - "strict-event-emitter": "^0.5.1", - "tough-cookie": "^6.0.0", - "type-fest": "^5.2.0", - "until-async": "^3.0.2", - "yargs": "^17.7.2" - }, - "bin": { - "msw": "cli/index.js" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/mswjs" - }, - "peerDependencies": { - "typescript": ">= 4.8.x" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/msw/node_modules/tough-cookie": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", - "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "tldts": "^7.0.5" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/msw/node_modules/type-fest": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.3.1.tgz", - "integrity": "sha512-VCn+LMHbd4t6sF3wfU/+HKT63C9OoyrSIf4b+vtWHpt2U7/4InZG467YDNMFMR70DdHjAdpPWmw2lzRdg0Xqqg==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "dependencies": { - "tagged-tag": "^1.0.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/mute-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", - "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/node-abi": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.24.0.tgz", - "integrity": "sha512-u2EC1CeNe25uVtX3EZbdQ275c74zdZmmpzrHEQh2aIYqoVjlglfUpOX9YY85x1nlBydEKDVaSmMNhR7N82Qj8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.6.3" - }, - "engines": { - "node": ">=22.12.0" - } - }, - "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==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/node-addon-api": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-1.7.2.tgz", - "integrity": "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/node-api-version": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/node-api-version/-/node-api-version-0.2.1.tgz", - "integrity": "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.3.5" - } - }, - "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==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "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", - "integrity": "sha512-ra7Kvlhxn5V9Slyus0ygMa2h+UqExPqUIkfk7Pc8QTLT956JLSy51uWFwHtIYy0vI8cB4BDhc/S03+880My/LQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "env-paths": "^2.2.0", - "exponential-backoff": "^3.1.1", - "graceful-fs": "^4.2.6", - "make-fetch-happen": "^14.0.3", - "nopt": "^8.0.0", - "proc-log": "^5.0.0", - "semver": "^7.3.5", - "tar": "^7.4.3", - "tinyglobby": "^0.2.12", - "which": "^5.0.0" - }, - "bin": { - "node-gyp": "bin/node-gyp.js" - }, - "engines": { - "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==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "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", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/nopt": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz", - "integrity": "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==", - "dev": true, - "license": "ISC", - "dependencies": { - "abbrev": "^3.0.0" - }, - "bin": { - "nopt": "bin/nopt.js" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/normalize-url": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", - "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/object-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/obug": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", - "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", - "dev": true, - "funding": [ - "https://github.com/sponsors/sxzz", - "https://opencollective.com/debug" - ], - "license": "MIT" - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ora": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", - "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "bl": "^4.1.0", - "chalk": "^4.1.0", - "cli-cursor": "^3.1.0", - "cli-spinners": "^2.5.0", - "is-interactive": "^1.0.0", - "is-unicode-supported": "^0.1.0", - "log-symbols": "^4.1.0", - "strip-ansi": "^6.0.0", - "wcwidth": "^1.0.1" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/outvariant": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", - "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", - "dev": true, - "license": "MIT" - }, - "node_modules/p-cancelable": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", - "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-map": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", - "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true, - "license": "BlueOak-1.0.0" - }, - "node_modules/parse5": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", - "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", - "dev": true, - "license": "MIT", - "dependencies": { - "entities": "^6.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/path-scurry/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/path-to-regexp": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", - "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, - "node_modules/pe-library": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/pe-library/-/pe-library-0.4.1.tgz", - "integrity": "sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12", - "npm": ">=6" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/jet2jet" - } - }, - "node_modules/pend": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", - "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", - "dev": true, - "license": "MIT" - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/plist": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", - "integrity": "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@xmldom/xmldom": "^0.8.8", - "base64-js": "^1.5.1", - "xmlbuilder": "^15.1.1" - }, - "engines": { - "node": ">=10.4.0" - } - }, - "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/postject": { - "version": "1.0.0-alpha.6", - "resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz", - "integrity": "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "commander": "^9.4.0" - }, - "bin": { - "postject": "dist/cli.js" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/postject/node_modules/commander": { - "version": "9.5.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", - "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": "^12.20.0 || >=14" - } - }, - "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==", - "dev": true, - "license": "MIT", - "bin": { - "prettier": "bin/prettier.cjs" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, - "node_modules/pretty-format": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", - "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "ansi-regex": "^5.0.1", - "ansi-styles": "^5.0.0", - "react-is": "^17.0.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/pretty-format/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/proc-log": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-5.0.0.tgz", - "integrity": "sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/progress": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", - "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/promise-retry": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", - "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "err-code": "^2.0.2", - "retry": "^0.12.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "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", - "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", - "dev": true, - "license": "MIT", - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "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", - "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/react": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", - "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", - "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==", - "license": "MIT", - "dependencies": { - "scheduler": "^0.27.0" - }, - "peerDependencies": { - "react": "^19.2.3" - } - }, - "node_modules/react-is": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/react-refresh": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", - "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/read-binary-file-arch": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz", - "integrity": "sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.3.4" - }, - "bin": { - "read-binary-file-arch": "cli.js" - } - }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/redent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", - "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", - "dev": true, - "license": "MIT", - "dependencies": { - "indent-string": "^4.0.0", - "strip-indent": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, - "license": "MIT", - "engines": { - "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", - "integrity": "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==", - "dev": true, - "license": "MIT", - "dependencies": { - "pe-library": "^0.4.1" - }, - "engines": { - "node": ">=12", - "npm": ">=6" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/jet2jet" - } - }, - "node_modules/resolve-alpn": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", - "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", - "dev": true, - "license": "MIT" - }, - "node_modules/responselike": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", - "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", - "dev": true, - "license": "MIT", - "dependencies": { - "lowercase-keys": "^2.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/restore-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", - "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "onetime": "^5.1.0", - "signal-exit": "^3.0.2" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/retry": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", - "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/rettime": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.7.0.tgz", - "integrity": "sha512-LPRKoHnLKd/r3dVxcwO7vhCW+orkOGj9ViueosEBK6ie89CijnfRlhaDhHq/3Hxu4CkWQtxwlBG0mzTQY6uQjw==", - "dev": true, - "license": "MIT" - }, - "node_modules/rimraf": { - "version": "2.6.3", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", - "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "dev": true, - "license": "ISC", - "peer": true, - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - } - }, - "node_modules/roarr": { - "version": "2.15.4", - "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", - "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", - "dev": true, - "license": "BSD-3-Clause", - "optional": true, - "dependencies": { - "boolean": "^3.0.1", - "detect-node": "^2.0.4", - "globalthis": "^1.0.1", - "json-stringify-safe": "^5.0.1", - "semver-compare": "^1.0.0", - "sprintf-js": "^1.1.2" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/rollup": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", - "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.53.3", - "@rollup/rollup-android-arm64": "4.53.3", - "@rollup/rollup-darwin-arm64": "4.53.3", - "@rollup/rollup-darwin-x64": "4.53.3", - "@rollup/rollup-freebsd-arm64": "4.53.3", - "@rollup/rollup-freebsd-x64": "4.53.3", - "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", - "@rollup/rollup-linux-arm-musleabihf": "4.53.3", - "@rollup/rollup-linux-arm64-gnu": "4.53.3", - "@rollup/rollup-linux-arm64-musl": "4.53.3", - "@rollup/rollup-linux-loong64-gnu": "4.53.3", - "@rollup/rollup-linux-ppc64-gnu": "4.53.3", - "@rollup/rollup-linux-riscv64-gnu": "4.53.3", - "@rollup/rollup-linux-riscv64-musl": "4.53.3", - "@rollup/rollup-linux-s390x-gnu": "4.53.3", - "@rollup/rollup-linux-x64-gnu": "4.53.3", - "@rollup/rollup-linux-x64-musl": "4.53.3", - "@rollup/rollup-openharmony-arm64": "4.53.3", - "@rollup/rollup-win32-arm64-msvc": "4.53.3", - "@rollup/rollup-win32-ia32-msvc": "4.53.3", - "@rollup/rollup-win32-x64-gnu": "4.53.3", - "@rollup/rollup-win32-x64-msvc": "4.53.3", - "fsevents": "~2.3.2" - } - }, - "node_modules/rxjs": { - "version": "7.8.2", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", - "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.1.0" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/sanitize-filename": { - "version": "1.6.3", - "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.3.tgz", - "integrity": "sha512-y/52Mcy7aw3gRm7IrcGDFx/bCk4AhRh2eI9luHOQM86nZsqwiRkkq2GekHXBBD+SmPidc8i2PqtYZl+pWJ8Oeg==", - "dev": true, - "license": "WTFPL OR ISC", - "dependencies": { - "truncate-utf8-bytes": "^1.0.0" - } - }, - "node_modules/sax": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.4.tgz", - "integrity": "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==", - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=11.0.0" - } - }, - "node_modules/saxes": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", - "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", - "dev": true, - "license": "ISC", - "dependencies": { - "xmlchars": "^2.2.0" - }, - "engines": { - "node": ">=v12.22.7" - } - }, - "node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "license": "MIT" - }, - "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/semver-compare": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", - "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/serialize-error": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", - "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "type-fest": "^0.13.1" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "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", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/shell-quote": { - "version": "1.8.3", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", - "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, - "node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/simple-update-notifier": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", - "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/simple-update-notifier/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/slice-ansi": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz", - "integrity": "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "ansi-styles": "^4.0.0", - "astral-regex": "^2.0.0", - "is-fullwidth-code-point": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/smart-buffer": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", - "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks": { - "version": "2.8.7", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", - "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ip-address": "^10.0.1", - "smart-buffer": "^4.2.0" - }, - "engines": { - "node": ">= 10.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks-proxy-agent": { - "version": "8.0.5", - "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", - "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "socks": "^2.8.3" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, - "node_modules/sprintf-js": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", - "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", - "dev": true, - "license": "BSD-3-Clause", - "optional": true - }, - "node_modules/ssri": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/ssri/-/ssri-12.0.0.tgz", - "integrity": "sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.3" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, - "node_modules/stat-mode": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz", - "integrity": "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", - "dev": true, - "license": "MIT" - }, - "node_modules/strict-event-emitter": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", - "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, - "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-indent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", - "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "min-indent": "^1.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/sumchecker": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", - "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "debug": "^4.1.0" - }, - "engines": { - "node": ">= 8.0" - } - }, - "node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/symbol-tree": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", - "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", - "dev": true, - "license": "MIT" - }, - "node_modules/tagged-tag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", - "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "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==", - "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", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/tar/node_modules/minipass": { - "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==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "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", - "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "mkdirp": "^0.5.1", - "rimraf": "~2.6.2" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/temp-file": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/temp-file/-/temp-file-3.4.0.tgz", - "integrity": "sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==", - "dev": true, - "license": "MIT", - "dependencies": { - "async-exit-hook": "^2.0.1", - "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", - "integrity": "sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^5.5.0" - } - }, - "node_modules/tiny-async-pool/node_modules/semver": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver" - } - }, - "node_modules/tiny-typed-emitter": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz", - "integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==", - "license": "MIT" - }, - "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyexec": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", - "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.3" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tinyrainbow": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", - "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tldts": { - "version": "7.0.19", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.19.tgz", - "integrity": "sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==", - "dev": true, - "license": "MIT", - "dependencies": { - "tldts-core": "^7.0.19" - }, - "bin": { - "tldts": "bin/cli.js" - } - }, - "node_modules/tldts-core": { - "version": "7.0.19", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.19.tgz", - "integrity": "sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==", - "dev": true, - "license": "MIT" - }, - "node_modules/tmp": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", - "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.14" - } - }, - "node_modules/tmp-promise": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/tmp-promise/-/tmp-promise-3.0.3.tgz", - "integrity": "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "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", - "dependencies": { - "psl": "^1.1.33", - "punycode": "^2.1.1", - "universalify": "^0.2.0", - "url-parse": "^1.5.3" - }, - "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_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", - "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", - "dev": true, - "license": "MIT", - "bin": { - "tree-kill": "cli.js" - } - }, - "node_modules/truncate-utf8-bytes": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", - "integrity": "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==", - "dev": true, - "license": "WTFPL", - "dependencies": { - "utf8-byte-length": "^1.0.1" - } - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD" - }, - "node_modules/type-fest": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", - "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "optional": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "dev": true, - "license": "MIT" - }, - "node_modules/unique-filename": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-4.0.0.tgz", - "integrity": "sha512-XSnEewXmQ+veP7xX2dS5Q4yZAvO40cBN2MWkJ7D/6sW4Dg6wYBNwM1Vrnz1FhH5AdeLIlUXRI9e28z1YZi71NQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "unique-slug": "^5.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/unique-slug": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-5.0.0.tgz", - "integrity": "sha512-9OdaqO5kwqR+1kVgHAhsp5vPNU0hnxRa26rBFNfNgM7M6pNtgzeBn3s/xbyCQL3dcjzOatcef6UUHpB/6MaETg==", - "dev": true, - "license": "ISC", - "dependencies": { - "imurmurhash": "^0.1.4" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4.0.0" - } - }, - "node_modules/until-async": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/until-async/-/until-async-3.0.2.tgz", - "integrity": "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/kettanaito" - } - }, - "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==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "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", - "integrity": "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==", - "dev": true, - "license": "(WTFPL OR MIT)" - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, - "license": "MIT" - }, - "node_modules/verror": { - "version": "1.10.1", - "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz", - "integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "assert-plus": "^1.0.0", - "core-util-is": "1.0.2", - "extsprintf": "^1.2.0" - }, - "engines": { - "node": ">=0.6.0" - } - }, - "node_modules/vite": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", - "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.27.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "lightningcss": "^1.21.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/vite-plugin-electron": { - "version": "0.29.0", - "resolved": "https://registry.npmjs.org/vite-plugin-electron/-/vite-plugin-electron-0.29.0.tgz", - "integrity": "sha512-HP0DI9Shg41hzt55IKYVnbrChWXHX95QtsEQfM+szQBpWjVhVGMlqRjVco6ebfQjWNr+Ga+PeoBjMIl8zMaufw==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "vite-plugin-electron-renderer": "*" - }, - "peerDependenciesMeta": { - "vite-plugin-electron-renderer": { - "optional": true - } - } - }, - "node_modules/vite-plugin-electron-renderer": { - "version": "0.14.6", - "resolved": "https://registry.npmjs.org/vite-plugin-electron-renderer/-/vite-plugin-electron-renderer-0.14.6.tgz", - "integrity": "sha512-oqkWFa7kQIkvHXG7+Mnl1RTroA4sP0yesKatmAy0gjZC4VwUqlvF9IvOpHd1fpLWsqYX/eZlVxlhULNtaQ78Jw==", - "dev": true, - "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==", - "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", - "es-module-lexer": "^1.7.0", - "expect-type": "^1.2.2", - "magic-string": "^0.30.21", - "obug": "^2.1.1", - "pathe": "^2.0.3", - "picomatch": "^4.0.3", - "std-env": "^3.10.0", - "tinybench": "^2.9.0", - "tinyexec": "^1.0.2", - "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.0.3", - "vite": "^6.0.0 || ^7.0.0", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@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", - "happy-dom": "*", - "jsdom": "*" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@opentelemetry/api": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser-playwright": { - "optional": true - }, - "@vitest/browser-preview": { - "optional": true - }, - "@vitest/browser-webdriverio": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - } - } - }, - "node_modules/w3c-xmlserializer": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", - "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", - "dev": true, - "license": "MIT", - "dependencies": { - "xml-name-validator": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/wait-on": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-9.0.3.tgz", - "integrity": "sha512-13zBnyYvFDW1rBvWiJ6Av3ymAaq8EDQuvxZnPIw3g04UqGi4TyoIJABmfJ6zrvKo9yeFQExNkOk7idQbDJcuKA==", - "dev": true, - "license": "MIT", - "dependencies": { - "axios": "^1.13.2", - "joi": "^18.0.1", - "lodash": "^4.17.21", - "minimist": "^1.2.8", - "rxjs": "^7.8.2" - }, - "bin": { - "wait-on": "bin/wait-on" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/wcwidth": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", - "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", - "dev": true, - "license": "MIT", - "dependencies": { - "defaults": "^1.0.3" - } - }, - "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" - }, - "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==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "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==", - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, - "node_modules/which": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", - "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^3.1.1" - }, - "bin": { - "node-which": "bin/which.js" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, - "bin": { - "why-is-node-running": "cli.js" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "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", - "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/xmlbuilder": { - "version": "15.1.1", - "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", - "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.0" - } - }, - "node_modules/xmlchars": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", - "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", - "dev": true, - "license": "MIT" - }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true, - "license": "ISC" - }, - "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/yauzl": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", - "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer-crc32": "~0.2.3", - "fd-slicer": "~1.1.0" - } - }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/yoctocolors-cjs": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", - "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - } - } -} diff --git a/frontend/package.json b/frontend/package.json deleted file mode 100644 index 1ac6be70..00000000 --- a/frontend/package.json +++ /dev/null @@ -1,107 +0,0 @@ -{ - "name": "slide-generator", - "productName": "Slide Generator", - "version": "1.1.0", - "description": "Generate presentation slides from predefined template", - "main": "dist-electron/main.js", - "type": "commonjs", - "license": "GPL-3.0-only", - "repository": { - "type": "git", - "url": "https://github.com/thnhmai06/SlideGenerator.git" - }, - "scripts": { - "dev": "vite", - "build:types": "tsc -p tsconfig.node.json", - "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:full": "npm run build:backend && npm run build", - "preview": "vite preview", - "test": "vitest run", - "test:watch": "vitest", - "format": "prettier --write ." - }, - "private": true, - "keywords": [ - "electron", - "react", - "typescript", - "vite" - ], - "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" - }, - "devDependencies": { - "@testing-library/jest-dom": "^6.9.1", - "@testing-library/react": "^16.3.1", - "@testing-library/user-event": "^14.6.1", - "@types/react": "^19.2.8", - "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^5.1.2", - "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", - "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" - }, - "build": { - "appId": "com.thnhmai06.slide-generator", - "directories": { - "output": "release" - }, - "files": [ - "dist/**/*", - "dist-electron/**/*", - "package.json", - "assets/**/*" - ], - "extraResources": [ - { - "from": "backend", - "to": "backend" - } - ], - "publish": { - "provider": "github", - "owner": "thnhmai06", - "repo": "SlideGenerator", - "releaseType": "release" - }, - "win": { - "icon": "assets/images/app-icon.ico", - "target": [ - "nsis", - "portable" - ] - }, - "mac": { - "icon": "assets/images/app-icon.icns", - "target": [ - "dmg" - ], - "category": "public.app-category.productivity" - }, - "linux": { - "icon": "assets/images/app-icon.png", - "target": [ - "AppImage", - "tar.gz" - ], - "category": "Utility" - } - } -} diff --git a/frontend/src/app/App.css b/frontend/src/app/App.css deleted file mode 100644 index bb0f4f4e..00000000 --- a/frontend/src/app/App.css +++ /dev/null @@ -1,115 +0,0 @@ -.app-shell { - display: flex; - flex-direction: column; - height: 100vh; - width: 100%; -} - -.connection-banner { - height: 0; - overflow: hidden; - opacity: 0; - transform: translateY(-100%); - transition: - height 0.3s ease, - opacity 0.3s ease, - transform 0.3s ease; - background: transparent; -} - -.connection-banner.connected, -.connection-banner.disconnected { - height: 36px; - opacity: 1; - transform: translateY(0); -} - -.connection-banner__content { - height: 100%; - display: flex; - align-items: center; - justify-content: center; - padding: 0 var(--spacing-md); - font-size: var(--font-sm); - font-weight: var(--font-semibold); - color: #fff; -} - -.connection-banner.connected .connection-banner__content { - background: linear-gradient(90deg, #10b981, #22c55e); -} - -.connection-banner.disconnected .connection-banner__content { - background: linear-gradient(90deg, #ef4444, #f97316); -} - -.app-container { - flex: 1; - min-height: 0; - display: grid; - grid-template-columns: 240px 1fr; - gap: var(--spacing-2xl); - width: 100%; - padding: var(--spacing-2xl); -} - -.main-content { - position: relative; - background: var(--bg-secondary); - border: 1px solid var(--border-primary); - border-radius: var(--radius-xl); - padding: var(--spacing-3xl); - box-shadow: var(--shadow-lg); - overflow-y: auto; -} - -.main-content::before { - content: ''; - position: absolute; - inset: 0; - border-radius: inherit; - background: linear-gradient(160deg, rgba(255, 255, 255, 0.04), transparent 60%); - pointer-events: none; - z-index: 0; -} - -.main-content > * { - position: relative; - z-index: 1; -} - -@media (max-width: 980px) { - .app-container { - grid-template-columns: 1fr; - padding: var(--spacing-lg); - gap: var(--spacing-lg); - } - - .main-content { - padding: var(--spacing-2xl); - } -} - -/* Lazy loading spinner */ -.menu-loader { - display: flex; - align-items: center; - justify-content: center; - height: 100%; - min-height: 200px; -} - -.menu-loader-spinner { - width: 40px; - height: 40px; - border: 3px solid var(--border-primary); - border-top-color: var(--accent-primary); - border-radius: 50%; - animation: spin 0.8s linear infinite; -} - -@keyframes spin { - to { - transform: rotate(360deg); - } -} diff --git a/frontend/src/app/App.tsx b/frontend/src/app/App.tsx deleted file mode 100644 index a062d06c..00000000 --- a/frontend/src/app/App.tsx +++ /dev/null @@ -1,162 +0,0 @@ -import React, { lazy, Suspense, useEffect, useMemo, useRef, useState, useCallback } from 'react'; -import Sidebar from '@/shared/components/Sidebar'; -import TitleBar from '@/shared/components/TitleBar'; -import { checkHealth } from '@/shared/services/backendApi'; -import { useApp } from '@/shared/contexts/useApp'; -import { useJobs } from '@/shared/contexts/useJobs'; -import './App.css'; - -// Lazy load feature components for code splitting -const CreateTaskMenu = lazy(() => import('@/features/create-task')); -const SettingMenu = lazy(() => import('@/features/settings')); -const ProcessMenu = lazy(() => import('@/features/process')); -const ResultMenu = lazy(() => import('@/features/results')); -const AboutMenu = lazy(() => import('@/features/about')); - -// Loading fallback component -const MenuLoader: React.FC = () => ( -
-
-
-); - -type MenuType = 'input' | 'setting' | 'download' | 'process' | 'about'; - -const App: React.FC = () => { - const { t } = useApp(); - const { groups } = useJobs(); - const [currentMenu, setCurrentMenu] = useState('input'); - const [bannerState, setBannerState] = useState<'hidden' | 'connected' | 'disconnected'>('hidden'); - const bannerTimeoutRef = useRef(null); - const connectionRef = useRef<'connected' | 'disconnected' | 'unknown'>('unknown'); - - useEffect(() => { - const clearBannerTimeout = () => { - if (bannerTimeoutRef.current !== null) { - window.clearTimeout(bannerTimeoutRef.current); - bannerTimeoutRef.current = null; - } - }; - - const showConnected = () => { - clearBannerTimeout(); - setBannerState('connected'); - bannerTimeoutRef.current = window.setTimeout(() => { - if (connectionRef.current === 'connected') { - setBannerState('hidden'); - } - }, 2000); - }; - - const showDisconnected = () => { - clearBannerTimeout(); - setBannerState('disconnected'); - }; - - const updateStatus = async () => { - try { - await checkHealth(); - if (connectionRef.current !== 'connected') { - connectionRef.current = 'connected'; - console.info('Backend connection restored.'); - showConnected(); - } - } catch { - if (connectionRef.current !== 'disconnected') { - connectionRef.current = 'disconnected'; - showDisconnected(); - } - } - }; - - updateStatus().catch(() => undefined); - const intervalId = window.setInterval(updateStatus, 5000); - return () => { - clearBannerTimeout(); - window.clearInterval(intervalId); - }; - }, []); - - useEffect(() => { - const allowedMenus: MenuType[] = ['input', 'setting', 'download', 'process', 'about']; - const isMenuType = (value: string): value is MenuType => - allowedMenus.includes(value as MenuType); - const unsubscribe = window.electronAPI?.onNavigate?.((menu) => { - if (isMenuType(menu)) { - setCurrentMenu(menu); - } - }); - - return () => { - if (typeof unsubscribe === 'function') { - unsubscribe(); - } - }; - }, []); - - const appTitle = t('app.title'); - const windowTitle = useMemo(() => { - const activeGroups = groups.filter((group) => - ['pending', 'running', 'paused'].includes(group.status.toLowerCase()), - ); - if (activeGroups.length === 0) return appTitle; - - let totalSlides = 0; - let completedSlides = 0; - activeGroups.forEach((group) => { - Object.values(group.sheets).forEach((sheet) => { - const total = sheet.totalRows ?? 0; - totalSlides += total; - completedSlides += Math.min(sheet.currentRow ?? 0, total); - }); - }); - - const percent = totalSlides > 0 ? Math.round((completedSlides / totalSlides) * 100) : 0; - return `${appTitle} - ${completedSlides}/${totalSlides} ${t('process.slides')} (${percent}%)`; - }, [appTitle, groups, t]); - - useEffect(() => { - document.title = windowTitle; - }, [windowTitle]); - - const handleStart = useCallback(() => { - setCurrentMenu('process'); - }, []); - - const renderMenu = useMemo(() => { - switch (currentMenu) { - case 'input': - return ; - case 'setting': - return ; - case 'download': - return ; - case 'process': - return ; - case 'about': - return ; - default: - return ; - } - }, [currentMenu, handleStart]); - - return ( -
- -
-
- {bannerState === 'disconnected' && t('connection.disconnected')} - {bannerState === 'connected' && t('connection.connected')} -
-
-
- -
- }>{renderMenu} -
-
-
- ); -}; - -export default App; diff --git a/frontend/src/app/providers/AppProviders.tsx b/frontend/src/app/providers/AppProviders.tsx deleted file mode 100644 index 0bc3c7cf..00000000 --- a/frontend/src/app/providers/AppProviders.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import React from 'react'; -import { AppProvider } from '@/shared/contexts/AppContext'; -import { JobProvider } from '@/shared/contexts/JobContext'; -import { UpdaterProvider } from '@/shared/contexts/UpdaterContext'; - -type AppProvidersProps = { - children: React.ReactNode; -}; - -const AppProviders: React.FC = ({ children }) => ( - - - {children} - - -); - -export default AppProviders; diff --git a/frontend/src/features/about/AboutMenu.css b/frontend/src/features/about/AboutMenu.css deleted file mode 100644 index 8b725a7a..00000000 --- a/frontend/src/features/about/AboutMenu.css +++ /dev/null @@ -1,424 +0,0 @@ -.about-menu { - max-width: 840px; - margin: 0 auto; - display: flex; - flex-direction: column; - gap: var(--spacing-2xl); - animation: fadeInUp var(--transition-base); -} - -.about-menu .menu-title { - font-size: var(--font-3xl); - font-weight: var(--font-bold); -} - -.about-content { - padding: var(--spacing-3xl); - background-color: var(--bg-tertiary); - border-radius: var(--radius-lg); - border: 1px solid var(--border-primary); - box-shadow: var(--shadow-sm); - display: flex; - flex-direction: column; - gap: var(--spacing-2xl); - position: relative; -} - -.about-section h2 { - font-size: var(--font-2xl); - font-weight: var(--font-bold); - color: var(--accent-primary); - margin-bottom: var(--spacing-sm); -} - -.about-section h3 { - font-size: var(--font-lg); - font-weight: var(--font-semibold); - color: var(--text-primary); - margin-bottom: var(--spacing-sm); -} - -.version { - color: var(--text-tertiary); - font-size: var(--font-sm); -} - -.description { - color: var(--text-secondary); - font-size: var(--font-base); - line-height: 1.6; -} - -.about-links { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); - gap: var(--spacing-md); -} - -.developer-list { - display: flex; - flex-wrap: wrap; - gap: var(--spacing-sm); -} - -.developer-link { - border: 1px solid var(--border-primary); - background: var(--bg-secondary); - color: var(--text-primary); - padding: 6px 12px; - border-radius: var(--radius-full); - font-size: var(--font-sm); - font-weight: var(--font-semibold); - cursor: pointer; - display: inline-flex; - align-items: center; - gap: var(--spacing-sm); - transition: - border-color var(--transition-fast), - transform var(--transition-fast), - box-shadow var(--transition-fast); -} - -.developer-link:hover { - border-color: var(--accent-primary); - transform: translateY(-1px); - box-shadow: var(--shadow-sm); -} - -.developer-avatar { - width: 20px; - height: 20px; - border-radius: 50%; - object-fit: cover; - border: 1px solid var(--border-secondary); -} - -.link-btn { - padding: 12px 16px; - border-radius: var(--radius-md); - border: 1px solid var(--border-primary); - background: var(--bg-secondary); - color: var(--text-primary); - cursor: pointer; - font-weight: var(--font-semibold); - display: flex; - align-items: center; - gap: var(--spacing-sm); - transition: - border-color var(--transition-fast), - transform var(--transition-fast), - box-shadow var(--transition-fast); -} - -.link-btn:hover { - border-color: var(--accent-primary); - transform: translateY(-1px); - box-shadow: var(--shadow-sm); -} - -.link-icon { - width: 18px; - height: 18px; - object-fit: contain; - filter: brightness(0.7); -} - -[data-theme='light'] .link-icon { - filter: brightness(0.6); -} - -.about-footer { - text-align: center; - color: var(--text-tertiary); - font-size: var(--font-sm); - border-top: 1px solid var(--border-primary); - padding-top: var(--spacing-lg); -} - -.about-hero-gifs { - position: absolute; - top: 0; - right: 24px; - display: flex; - align-items: center; - gap: 0; - pointer-events: none; - z-index: 2; - transform: translateY(-100%); -} - -.about-hero-gif { - height: 64px; - width: auto; -} - -@media (max-width: 720px) { - .about-hero-gifs { - right: 16px; - gap: 0; - } - - .about-hero-gif { - height: 52px; - } -} - -.update-section { - background: var(--bg-secondary); - border-radius: var(--radius-md); - padding: var(--spacing-lg); - border: 1px solid var(--border-primary); -} - -.update-checker { - display: flex; - flex-direction: column; - gap: var(--spacing-md); -} - -.update-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--spacing-md); - flex-wrap: wrap; -} - -.update-current-version { - color: var(--text-secondary); - font-size: var(--font-sm); -} - -.update-btn { - padding: 8px 16px; - border-radius: var(--radius-md); - font-size: var(--font-sm); - font-weight: var(--font-semibold); - cursor: pointer; - transition: all var(--transition-fast); - border: 1px solid var(--border-primary); - background: var(--bg-tertiary); - color: var(--text-primary); -} - -.update-btn:hover { - border-color: var(--accent-primary); - transform: translateY(-1px); -} - -.update-btn:disabled { - opacity: 0.6; - cursor: not-allowed; - transform: none; -} - -.update-btn-primary { - background: var(--accent-primary); - border-color: var(--accent-primary); - color: white; -} - -.update-btn-primary:hover { - background: var(--accent-hover); - border-color: var(--accent-hover); -} - -.update-btn-check { - background: var(--bg-tertiary); -} - -.update-status { - display: flex; - align-items: center; - gap: var(--spacing-sm); - padding: var(--spacing-md); - border-radius: var(--radius-md); - background: var(--bg-tertiary); - flex-wrap: wrap; - min-height: 60px; /* Consistent height across all states */ -} - -.update-checking { - color: var(--text-secondary); -} - -.update-spinner { - width: 16px; - height: 16px; - border: 2px solid var(--border-primary); - border-top-color: var(--accent-primary); - border-radius: 50%; - animation: spin 0.8s linear infinite; -} - -@keyframes spin { - to { - transform: rotate(360deg); - } -} - -.update-available { - justify-content: space-between; - background: color-mix(in srgb, var(--accent-primary) 10%, transparent); - border: 1px solid var(--accent-primary); -} - -.update-info { - display: flex; - flex-direction: column; - gap: var(--spacing-xs); - flex: 1; -} - -/* Row layout for not-available and error states */ -.update-not-available .update-info, -.update-error .update-info { - flex-direction: row; - align-items: center; - gap: var(--spacing-sm); -} - -.update-badge { - display: inline-block; - padding: 2px 8px; - border-radius: var(--radius-sm); - font-size: var(--font-xs); - font-weight: var(--font-semibold); - background: var(--accent-primary); - color: white; -} - -.update-badge-success { - background: var(--status-completed); -} - -.update-version { - font-size: var(--font-sm); - color: var(--text-primary); - font-weight: var(--font-medium); -} - -.update-text { - font-size: var(--font-sm); - color: var(--text-primary); -} - -.update-downloading { - flex-direction: column; - align-items: stretch; - gap: var(--spacing-sm); -} - -.update-downloading .update-info { - flex-direction: row; - justify-content: space-between; - align-items: center; -} - -.update-progress-bar { - height: 8px; - width: 100%; - background: var(--bg-primary); - border-radius: var(--radius-full); - overflow: hidden; -} - -.update-progress-fill { - height: 100%; - background: var(--accent-primary); - transition: width 0.3s ease; -} - -.update-progress-text { - font-size: var(--font-sm); - color: var(--text-secondary); - font-weight: var(--font-medium); -} - -.update-downloaded { - justify-content: space-between; - background: color-mix(in srgb, var(--accent-primary) 10%, transparent); - border: 1px solid var(--accent-primary); -} - -.update-actions { - display: flex; - align-items: center; - gap: var(--spacing-md); -} - -.update-hint { - font-size: var(--font-xs); - color: var(--text-tertiary); -} - -.update-hint-disabled { - font-size: var(--font-sm); - color: var(--text-tertiary); - font-style: italic; - padding-left: var(--spacing-lg); -} - -.update-not-available { - color: var(--text-secondary); - font-size: var(--font-base); -} - -.update-check-icon { - color: var(--status-completed); - font-weight: var(--font-bold); - font-size: var(--font-lg); -} - -.update-error { - background: color-mix(in srgb, var(--status-failed) 10%, transparent); - border: 1px solid var(--status-failed); -} - -.update-error-content { - display: flex; - flex-direction: column; - gap: var(--spacing-xs); -} - -.update-error-icon { - color: var(--status-failed); - font-size: var(--font-lg); -} - -.update-error-detail { - font-size: var(--font-xs); - color: var(--text-tertiary); - word-break: break-word; -} - -.update-warning { - display: flex; - align-items: center; - gap: var(--spacing-sm); - padding: var(--spacing-sm) var(--spacing-md); - background: color-mix(in srgb, var(--status-pending) 15%, transparent); - border: 1px solid var(--status-pending); - border-radius: var(--radius-md); - font-size: var(--font-sm); - color: var(--text-secondary); -} - -.update-warning-icon { - color: var(--status-pending); - font-size: var(--font-base); -} - -.update-text-highlight { - display: inline-block; - font-size: var(--font-sm); - font-weight: var(--font-medium); - color: var(--accent-primary); -} - -.update-text-highlight-success { - display: inline-block; - font-size: var(--font-sm); - font-weight: var(--font-medium); - color: var(--accent-primary); -} diff --git a/frontend/src/features/about/AboutMenu.tsx b/frontend/src/features/about/AboutMenu.tsx deleted file mode 100644 index 579d1683..00000000 --- a/frontend/src/features/about/AboutMenu.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import React from 'react'; -import { useApp } from '@/shared/contexts/useApp'; -import { getAssetPath } from '@/shared/utils/paths'; -import { UpdateChecker } from './UpdateChecker'; -import './AboutMenu.css'; - -const AboutMenu: React.FC = () => { - const { t } = useApp(); - // const version = typeof __APP_VERSION__ === 'string' ? __APP_VERSION__ : ''; - const developers = [ - { name: 'thnhmai06', url: 'https://github.com/thnhmai06' }, - { name: 'NAV-adsf23fd', url: 'https://github.com/NAV-adsf23fd' }, - { name: 'Hair-Nguyeenx', url: 'https://github.com/Hair-Nguyeenx' }, - ]; - const handleOpenGithub = () => { - window.electronAPI.openUrl('https://github.com/thnhmai06/SlideGenerator'); - }; - - return ( -
-

{t('sideBar.about')}

- -
-
- - -
-
-

{t('about.appName')}

-

- {t('about.description')} -
- {t('about.details')} -

-
- -
-

{t('update.checkForUpdates')}

- -
- -
-

{t('about.developer')}

-
- {developers.map((dev) => ( - - ))} -
-
- -
- -
- -
-

{t('about.license')}

-
-
-
- ); -}; - -export default AboutMenu; diff --git a/frontend/src/features/about/UpdateChecker.tsx b/frontend/src/features/about/UpdateChecker.tsx deleted file mode 100644 index a7c0e93d..00000000 --- a/frontend/src/features/about/UpdateChecker.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import React from 'react'; -import { useApp } from '@/shared/contexts/useApp'; -import { useUpdater } from '@/shared/contexts/UpdaterContext'; - -export const UpdateChecker: React.FC = () => { - const { t } = useApp(); - const { state, portable, checkForUpdates, downloadUpdate, installUpdate, hasActiveJobs } = - useUpdater(); - - const currentVersion = typeof __APP_VERSION__ === 'string' ? __APP_VERSION__ : ''; - - const renderContent = () => { - if (portable) { - return ( -
-
- {t('update.portableUnsupported')} -
-
- ); - } - - switch (state.status) { - case 'checking': - return ( -
- - {t('update.checking')} -
- ); - - case 'available': - return ( -
-
- {t('update.available')} - - {t('update.newVersion')} {state.info?.version} - -
- -
- ); - - case 'downloading': - return ( -
-
- {t('update.downloading')} - {state.progress ?? 0}% -
-
-
-
-
- ); - - case 'downloaded': - return ( -
-
- - {t('update.downloaded')} - - - {t('update.newVersion')} {state.info?.version} - -
- {hasActiveJobs ? ( - {t('update.activeJobsWarning')} - ) : ( - - )} -
- ); - - case 'not-available': - return ( -
-
- - {t('update.notAvailable')} -
-
- ); - - case 'error': - return ( -
-
- -
- {t('update.error')} - {state.error && {state.error}} -
-
-
- ); - - case 'unsupported': - return ( -
-
- {t('update.portableUnsupported')} -
-
- ); - - default: - return null; - } - }; - - const showCheckButton = - !portable && - (state.status === 'idle' || state.status === 'not-available' || state.status === 'error'); - - return ( -
-
- - {t('update.currentVersion')} {currentVersion} - - {showCheckButton && ( - - )} -
- {renderContent()} -
- ); -}; - -export default UpdateChecker; diff --git a/frontend/src/features/about/__tests__/UpdateChecker.test.tsx b/frontend/src/features/about/__tests__/UpdateChecker.test.tsx deleted file mode 100644 index a1aad853..00000000 --- a/frontend/src/features/about/__tests__/UpdateChecker.test.tsx +++ /dev/null @@ -1,366 +0,0 @@ -import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import UpdateChecker from '../UpdateChecker'; -import { UpdaterProvider } from '@/shared/contexts/UpdaterContext'; - -const checkForUpdates = vi.fn(); -const downloadUpdate = vi.fn(); -const installUpdate = vi.fn(); -const onUpdateStatus = vi.fn(); -const isPortable = vi.fn(); - -vi.mock('@/shared/contexts/useApp', () => ({ - useApp: () => ({ t: (key: string) => key }), -})); - -// Mock useJobs with no active jobs by default -const mockGroups: { id: string; status: string; sheets: Record }[] = []; -vi.mock('@/shared/contexts/useJobs', () => ({ - useJobs: () => ({ groups: mockGroups }), -})); - -describe('UpdateChecker', () => { - beforeEach(() => { - vi.restoreAllMocks(); - checkForUpdates.mockReset(); - downloadUpdate.mockReset(); - installUpdate.mockReset(); - onUpdateStatus.mockReset(); - isPortable.mockReset(); - mockGroups.length = 0; - - // Default: not portable - isPortable.mockResolvedValue(false); - - window.electronAPI = { - isPortable, - checkForUpdates, - downloadUpdate, - installUpdate, - onUpdateStatus: (handler: (state: unknown) => void) => { - onUpdateStatus.mockImplementation(handler); - return () => {}; - }, - } as unknown as typeof window.electronAPI; - }); - - afterEach(() => { - window.electronAPI = undefined as unknown as typeof window.electronAPI; - }); - - it('renders current version and check button (non-portable)', async () => { - render( - - - , - ); - - expect(screen.getByText(/update.currentVersion/)).toBeInTheDocument(); - - await waitFor(() => { - expect(screen.getByRole('button', { name: 'update.checkForUpdates' })).toBeInTheDocument(); - }); - }); - - it('hides check button and shows portable message (portable)', async () => { - isPortable.mockResolvedValue(true); - - render( - - - , - ); - - await waitFor(() => { - expect( - screen.queryByRole('button', { name: 'update.checkForUpdates' }), - ).not.toBeInTheDocument(); - expect(screen.getByText('update.portableUnsupported')).toBeInTheDocument(); - }); - }); - - it('shows checking status when checking for updates', async () => { - checkForUpdates.mockImplementation( - () => - new Promise((resolve) => { - setTimeout(() => resolve({ status: 'not-available' }), 100); - }), - ); - - const user = userEvent.setup(); - render( - - - , - ); - - await waitFor(() => { - expect(screen.getByRole('button', { name: 'update.checkForUpdates' })).toBeInTheDocument(); - }); - - await user.click(screen.getByRole('button', { name: 'update.checkForUpdates' })); - - expect(screen.getByText('update.checking')).toBeInTheDocument(); - }); - - it('shows not-available status when up to date', async () => { - checkForUpdates.mockResolvedValue({ status: 'not-available' }); - - const user = userEvent.setup(); - render( - - - , - ); - - await waitFor(() => { - expect(screen.getByRole('button', { name: 'update.checkForUpdates' })).toBeInTheDocument(); - }); - - await user.click(screen.getByRole('button', { name: 'update.checkForUpdates' })); - - await waitFor(() => { - expect(screen.getByText('update.notAvailable')).toBeInTheDocument(); - }); - }); - - it('shows available status with download button when update available', async () => { - checkForUpdates.mockResolvedValue({ - status: 'available', - info: { version: '2.0.0' }, - }); - - const user = userEvent.setup(); - render( - - - , - ); - - await waitFor(() => { - expect(screen.getByRole('button', { name: 'update.checkForUpdates' })).toBeInTheDocument(); - }); - - await user.click(screen.getByRole('button', { name: 'update.checkForUpdates' })); - - await waitFor(() => { - expect(screen.getByText('update.available')).toBeInTheDocument(); - expect(screen.getByText(/2.0.0/)).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'update.download' })).toBeInTheDocument(); - }); - }); - - it('calls downloadUpdate when download button clicked', async () => { - checkForUpdates.mockResolvedValue({ - status: 'available', - info: { version: '2.0.0' }, - }); - downloadUpdate.mockResolvedValue(true); - - const user = userEvent.setup(); - render( - - - , - ); - - await waitFor(() => { - expect(screen.getByRole('button', { name: 'update.checkForUpdates' })).toBeInTheDocument(); - }); - - await user.click(screen.getByRole('button', { name: 'update.checkForUpdates' })); - - await waitFor(() => { - expect(screen.getByRole('button', { name: 'update.download' })).toBeInTheDocument(); - }); - - await user.click(screen.getByRole('button', { name: 'update.download' })); - - expect(downloadUpdate).toHaveBeenCalled(); - }); - - it('shows error status when check fails', async () => { - checkForUpdates.mockRejectedValue(new Error('Network error')); - - const user = userEvent.setup(); - render( - - - , - ); - - await waitFor(() => { - expect(screen.getByRole('button', { name: 'update.checkForUpdates' })).toBeInTheDocument(); - }); - - await user.click(screen.getByRole('button', { name: 'update.checkForUpdates' })); - - await waitFor(() => { - expect(screen.getByText('update.error')).toBeInTheDocument(); - expect(screen.getByText('Network error')).toBeInTheDocument(); - }); - }); - - it('shows install button when update downloaded', async () => { - checkForUpdates.mockResolvedValue({ - status: 'downloaded', - info: { version: '2.0.0' }, - }); - - const user = userEvent.setup(); - render( - - - , - ); - - await waitFor(() => { - expect(screen.getByRole('button', { name: 'update.checkForUpdates' })).toBeInTheDocument(); - }); - - await user.click(screen.getByRole('button', { name: 'update.checkForUpdates' })); - - await waitFor(() => { - expect(screen.getByText('update.downloaded')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'update.installNow' })).toBeInTheDocument(); - }); - }); - - it('calls installUpdate when install button clicked', async () => { - checkForUpdates.mockResolvedValue({ - status: 'downloaded', - info: { version: '2.0.0' }, - }); - - const user = userEvent.setup(); - render( - - - , - ); - - await waitFor(() => { - expect(screen.getByRole('button', { name: 'update.checkForUpdates' })).toBeInTheDocument(); - }); - - await user.click(screen.getByRole('button', { name: 'update.checkForUpdates' })); - - await waitFor(() => { - expect(screen.getByRole('button', { name: 'update.installNow' })).toBeInTheDocument(); - }); - - await user.click(screen.getByRole('button', { name: 'update.installNow' })); - - expect(installUpdate).toHaveBeenCalled(); - }); - - it('shows warning and hides install button when active jobs exist', async () => { - mockGroups.push({ - id: 'group-1', - status: 'Running', - sheets: { 'sheet-1': { status: 'Running' } }, - }); - - checkForUpdates.mockResolvedValue({ - status: 'downloaded', - info: { version: '2.0.0' }, - }); - - const user = userEvent.setup(); - render( - - - , - ); - - await waitFor(() => { - expect(screen.getByRole('button', { name: 'update.checkForUpdates' })).toBeInTheDocument(); - }); - - await user.click(screen.getByRole('button', { name: 'update.checkForUpdates' })); - - await waitFor(() => { - expect(screen.getByText('update.downloaded')).toBeInTheDocument(); - expect(screen.getByText('update.activeJobsWarning')).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'update.installNow' })).not.toBeInTheDocument(); - }); - }); - - it('shows warning when paused jobs exist', async () => { - mockGroups.push({ - id: 'group-1', - status: 'Paused', - sheets: { 'sheet-1': { status: 'Paused' } }, - }); - - checkForUpdates.mockResolvedValue({ - status: 'downloaded', - info: { version: '2.0.0' }, - }); - - const user = userEvent.setup(); - render( - - - , - ); - - await waitFor(() => { - expect(screen.getByRole('button', { name: 'update.checkForUpdates' })).toBeInTheDocument(); - }); - - await user.click(screen.getByRole('button', { name: 'update.checkForUpdates' })); - - await waitFor(() => { - expect(screen.getByText('update.activeJobsWarning')).toBeInTheDocument(); - }); - }); - - it('allows install when all jobs are completed', async () => { - mockGroups.push({ - id: 'group-1', - status: 'Completed', - sheets: { 'sheet-1': { status: 'Completed' } }, - }); - - checkForUpdates.mockResolvedValue({ - status: 'downloaded', - info: { version: '2.0.0' }, - }); - - const user = userEvent.setup(); - render( - - - , - ); - - await waitFor(() => { - expect(screen.getByRole('button', { name: 'update.checkForUpdates' })).toBeInTheDocument(); - }); - - await user.click(screen.getByRole('button', { name: 'update.checkForUpdates' })); - - await waitFor(() => { - expect(screen.getByRole('button', { name: 'update.installNow' })).toBeInTheDocument(); - expect(screen.queryByText('update.activeJobsWarning')).not.toBeInTheDocument(); - }); - }); - - it('hides check button when running in portable mode', async () => { - // portable = true - window.electronAPI.isPortable = vi.fn().mockResolvedValue(true); - - render( - - - , - ); - - await waitFor(() => { - expect( - screen.queryByRole('button', { name: 'update.checkForUpdates' }), - ).not.toBeInTheDocument(); - }); - }); -}); diff --git a/frontend/src/features/about/index.ts b/frontend/src/features/about/index.ts deleted file mode 100644 index 1db402f1..00000000 --- a/frontend/src/features/about/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from './AboutMenu'; diff --git a/frontend/src/features/create-task/CreateTaskMenu.css b/frontend/src/features/create-task/CreateTaskMenu.css deleted file mode 100644 index ab33b6e3..00000000 --- a/frontend/src/features/create-task/CreateTaskMenu.css +++ /dev/null @@ -1,766 +0,0 @@ -.input-menu { - max-width: 1080px; - margin: 0 auto; - display: flex; - flex-direction: column; - gap: var(--spacing-2xl); - animation: fadeInUp var(--transition-base); -} - -.config-actions { - display: flex; - flex-wrap: wrap; - gap: var(--spacing-sm); -} - -.input-section { - padding: var(--spacing-xl); - background-color: var(--bg-tertiary); - border: 1px solid var(--border-primary); - border-radius: var(--radius-lg); - box-shadow: var(--shadow-sm); - display: flex; - flex-direction: column; - gap: var(--spacing-sm); -} - -.input-meta { - display: flex; - flex-wrap: wrap; - gap: var(--spacing-sm); - font-size: var(--font-sm); - color: var(--text-secondary); -} - -.input-meta-title { - font-weight: var(--font-semibold); - color: var(--text-primary); -} - -.sheet-selector { - padding: var(--spacing-md); - border-radius: var(--radius-md); - border: 1px solid var(--border-primary); - background: linear-gradient(140deg, rgba(255, 255, 255, 0.04), rgba(0, 0, 0, 0)); - display: flex; - flex-direction: column; - gap: var(--spacing-sm); -} - -.sheet-selector-header { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--spacing-sm); - font-size: var(--font-sm); - color: var(--text-secondary); - padding-bottom: var(--spacing-sm); - border-bottom: 1px dashed var(--border-primary); -} - -.sheet-selector-toggle { - display: inline-flex; - align-items: center; - gap: var(--spacing-sm); - border: none; - background: transparent; - cursor: pointer; - padding: 0; - color: inherit; - transition: color var(--transition-fast); - flex: 1 1 auto; - justify-content: flex-start; - min-width: 140px; -} - -.sheet-selector-toggle:hover { - color: var(--text-primary); -} - -.sheet-toggle-icon { - width: 12px; - height: 12px; - object-fit: contain; - opacity: 0.8; - filter: brightness(0) invert(1); - transition: transform var(--transition-fast); -} - -[data-theme='light'] .sheet-toggle-icon { - filter: none; -} - -.sheet-toggle-icon.is-open { - transform: rotate(180deg); -} - -.sheet-selector-title { - font-weight: var(--font-semibold); - color: var(--text-primary); -} - -.sheet-selector-total { - margin-left: var(--spacing-xs); - color: var(--text-tertiary); - font-weight: var(--font-medium); -} - -.sheet-selector-all { - display: inline-flex; - align-items: center; - gap: var(--spacing-xs); - cursor: pointer; - padding: 4px 10px; - border-radius: var(--radius-full); - border: 1px solid var(--border-primary); - background: var(--bg-tertiary); - transform: translateY(2px); -} - -.sheet-selector-count { - color: var(--text-tertiary); - font-weight: var(--font-medium); - margin-left: var(--spacing-xs); -} - -.sheet-selector-list { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); - gap: var(--spacing-sm); - padding-top: var(--spacing-xs); - max-height: 180px; - overflow-y: auto; - scrollbar-gutter: stable; - padding-right: 4px; - transition: - max-height var(--transition-base), - opacity var(--transition-base), - transform var(--transition-base); - transform-origin: top; -} - -.sheet-selector-list.is-collapsed { - max-height: 0; - opacity: 0; - overflow: hidden; - transform: translateY(-4px); - padding-top: 0; -} - -.sheet-selector-list.is-open { - opacity: 1; -} - -.sheet-selector-item { - display: inline-flex; - align-items: center; - gap: var(--spacing-xs); - font-size: var(--font-sm); - color: var(--text-secondary); - cursor: pointer; - padding: 6px 10px; - border-radius: var(--radius-full); - border: 1px solid var(--border-primary); - background: rgba(255, 255, 255, 0.02); - transition: - border-color var(--transition-fast), - background-color var(--transition-fast), - transform var(--transition-fast); -} - -.sheet-selector-item:hover { - border-color: var(--accent-primary); - background: rgba(59, 130, 246, 0.08); - transform: translateY(-1px); -} - -.sheet-selector-item input { - accent-color: var(--accent-primary); - transform: scale(1.05); -} - -.sheet-selector-name { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.replacement-section-separated { - display: flex; - flex-direction: column; - gap: var(--spacing-2xl); -} - -.replacement-full-panel { - padding: var(--spacing-2xl); - background-color: var(--bg-tertiary); - border: 1px solid var(--border-primary); - border-radius: var(--radius-lg); - box-shadow: var(--shadow-sm); - display: flex; - flex-direction: column; - gap: var(--spacing-lg); - position: relative; - transition: z-index 0s; -} - -.replacement-full-panel:focus-within { - z-index: 10; -} - -.panel-content { - max-height: 0; - opacity: 0; - overflow: hidden; - transform: translateY(-6px); - transition: - max-height var(--transition-base), - opacity var(--transition-base), - transform var(--transition-base); -} - -.panel-content.is-open { - max-height: 2000px; - opacity: 1; - transform: translateY(0); - overflow: visible; -} - -.panel-header { - display: flex; - justify-content: space-between; - align-items: center; - gap: var(--spacing-md); -} - -.panel-title { - display: flex; - align-items: center; - gap: var(--spacing-sm); - flex: 1; -} - -.panel-title-toggle { - display: inline-flex; - align-items: center; - gap: var(--spacing-sm); - border: none; - background: transparent; - padding: 0; - cursor: pointer; - color: inherit; - width: 100%; - justify-content: flex-start; -} - -.panel-title-toggle:disabled { - cursor: not-allowed; - opacity: 0.6; -} - -.replacement-full-panel .btn:disabled { - cursor: not-allowed; -} - -.panel-title-icon { - width: 14px; - height: 14px; - object-fit: contain; - transition: transform var(--transition-fast); - filter: brightness(0) invert(1); -} - -.panel-title-icon.expanded { - transform: rotate(180deg); -} - -[data-theme='light'] .panel-title-icon { - filter: none; -} - -.replacement-disabled { - opacity: 0.6; - cursor: not-allowed; -} - -.replacement-disabled * { - pointer-events: none; -} - -.panel-header h3 { - font-size: var(--font-xl); - font-weight: var(--font-semibold); - color: var(--text-primary); -} - -.panel-count { - color: var(--text-tertiary); - font-weight: var(--font-medium); -} - -.replacement-table { - display: flex; - flex-direction: column; - gap: var(--spacing-md); -} - -.replacement-table-text { - --table-col-main: 45%; - --table-col-narrow: 18%; - --table-col-action: 56px; -} - -.replacement-table-image { - --table-col-main: 26%; - --table-col-narrow: 18%; - --table-col-action: 56px; -} - -.replacement-table-grid { - width: 100%; - table-layout: fixed; - border-collapse: separate; - border-spacing: 0 var(--spacing-md); - overflow: visible; - position: relative; -} - -.replacement-table-grid col.col-main { - width: var(--table-col-main); -} - -.replacement-table-grid col.col-narrow { - width: var(--table-col-narrow); -} - -.replacement-table-grid col.col-action { - width: var(--table-col-action); -} - -.replacement-table-grid thead th { - text-align: left; - padding: var(--spacing-sm); - color: var(--text-tertiary); - font-size: var(--font-sm); - font-weight: var(--font-semibold); - text-transform: uppercase; - letter-spacing: 0.5px; - vertical-align: middle; -} - -.replacement-table-grid thead th.cell-action { - text-align: center; - padding-left: 0; - padding-right: 0; -} - -.replacement-table-grid tbody tr { - position: relative; -} - -.replacement-table-grid tbody tr:focus-within { - z-index: 20; -} - -.replacement-table-grid tbody td { - padding: var(--spacing-sm); - vertical-align: top; - background: rgba(255, 255, 255, 0.02); - border-top: 1px solid transparent; - border-bottom: 1px solid transparent; - overflow: visible; -} - -.replacement-table-grid tbody td:first-child { - border-left: 1px solid transparent; - border-top-left-radius: var(--radius-md); - border-bottom-left-radius: var(--radius-md); -} - -.replacement-table-grid tbody td:last-child { - border-right: 1px solid transparent; - border-top-right-radius: var(--radius-md); - border-bottom-right-radius: var(--radius-md); - z-index: 1; -} - -.replacement-table-grid tbody td .select-with-hint, -.replacement-table-grid tbody td .select-with-hint select, -.replacement-table-grid tbody td .select-with-hint input { - position: relative; - z-index: 4; -} - -.replacement-table-grid tbody tr:hover td { - border-color: var(--border-secondary); -} - -.replacement-table-grid .cell-action { - text-align: center; - vertical-align: middle; - padding-left: 0; - padding-right: 0; -} - -.replacement-table-grid .cell-action .delete-btn { - margin: 0 auto; -} - -.shape-gallery { - display: flex; - flex-direction: column; - gap: var(--spacing-sm); - padding: var(--spacing-md); - border-radius: var(--radius-md); - background: var(--bg-secondary); - border: 1px solid var(--border-primary); -} - -.shape-gallery-header { - font-size: var(--font-sm); - font-weight: var(--font-semibold); - color: var(--text-secondary); - text-transform: uppercase; - letter-spacing: 0.4px; -} - -.shape-gallery-list { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); - gap: var(--spacing-sm); -} - -.shape-gallery-item { - display: flex; - align-items: center; - gap: var(--spacing-sm); - padding: var(--spacing-sm); - border-radius: var(--radius-md); - background: rgba(255, 255, 255, 0.03); - border: 1px solid var(--border-primary); - width: 100%; - text-align: left; - cursor: pointer; - transition: - border-color var(--transition-fast), - background-color var(--transition-fast), - transform var(--transition-fast); -} - -.shape-gallery-item:hover { - border-color: var(--accent-primary); - background: rgba(59, 130, 246, 0.08); - transform: translateY(-1px); -} - -.shape-gallery-preview { - width: 36px; - height: 36px; - object-fit: contain; - border-radius: 8px; - background: var(--bg-tertiary); - border: 1px solid var(--border-secondary); - padding: 4px; -} - -.shape-gallery-info { - display: flex; - flex-direction: column; - gap: 2px; - min-width: 0; -} - -.shape-gallery-name { - font-size: var(--font-sm); - font-weight: var(--font-semibold); - color: var(--text-primary); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.shape-gallery-id { - font-size: var(--font-xs); - color: var(--text-tertiary); -} - -.shape-gallery-empty { - padding: var(--spacing-sm); - font-size: var(--font-sm); - color: var(--text-tertiary); -} - -.shape-preview-overlay { - position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.6); - display: flex; - align-items: center; - justify-content: center; - z-index: 2000; - padding: var(--spacing-2xl); - animation: preview-overlay-in 180ms ease; -} - -.shape-preview-overlay.is-closing { - animation: preview-overlay-out 160ms ease forwards; -} - -.shape-preview-modal { - width: min(880px, 90vw); - max-height: 90vh; - background: var(--bg-secondary); - border-radius: var(--radius-lg); - border: 1px solid var(--border-primary); - box-shadow: var(--shadow-lg); - display: flex; - flex-direction: column; - gap: var(--spacing-md); - padding: var(--spacing-xl); - animation: preview-pop 180ms ease; -} - -.shape-preview-modal.is-closing { - animation: preview-close 160ms ease forwards; -} - -.shape-preview-header { - display: flex; - justify-content: space-between; - align-items: center; - gap: var(--spacing-md); -} - -.shape-preview-title { - font-size: var(--font-lg); - font-weight: var(--font-semibold); - color: var(--text-primary); -} - -.shape-preview-close { - border: 1px solid var(--border-primary); - background: var(--bg-tertiary); - color: var(--text-primary); - border-radius: var(--radius-md); - padding: 6px 12px; - cursor: pointer; -} - -.shape-preview-meta { - display: flex; - flex-wrap: wrap; - gap: var(--spacing-md); - color: var(--text-tertiary); - font-size: var(--font-sm); -} - -.shape-preview-name { - color: var(--text-primary); - font-weight: var(--font-semibold); -} - -.shape-preview-id { - color: var(--text-tertiary); - font-weight: var(--font-medium); -} - -.shape-preview-actions { - display: flex; - align-items: center; - gap: var(--spacing-sm); - flex-wrap: wrap; -} - -.shape-preview-btn { - border: 1px solid var(--border-primary); - background: var(--bg-tertiary); - color: var(--text-primary); - border-radius: var(--radius-md); - padding: 6px 10px; - cursor: pointer; - display: inline-flex; - align-items: center; - gap: var(--spacing-xs); -} - -.shape-preview-zoom { - color: var(--text-secondary); - font-size: var(--font-sm); - min-width: 90px; - text-align: center; -} - -.shape-preview-body { - flex: 1; - min-height: 280px; - background: var(--bg-tertiary); - border-radius: var(--radius-md); - border: 1px solid var(--border-primary); - display: flex; - align-items: center; - justify-content: center; - overflow: hidden; -} - -.shape-preview-frame { - display: inline-flex; - align-items: center; - justify-content: center; - padding: var(--spacing-lg); -} - -.shape-preview-image { - max-width: 100%; - max-height: 100%; - transform-origin: center; - transition: transform 140ms ease; - cursor: zoom-in; - user-select: none; - touch-action: none; -} - -.shape-preview-image.zoomed { - cursor: grab; -} - -.shape-preview-image.zoomed:active { - cursor: grabbing; -} - -@keyframes preview-pop { - from { - opacity: 0; - transform: scale(0.96); - } - to { - opacity: 1; - transform: scale(1); - } -} - -@keyframes preview-close { - from { - opacity: 1; - transform: scale(1); - } - to { - opacity: 0; - transform: scale(0.96); - } -} - -@keyframes preview-overlay-in { - from { - opacity: 0; - } - to { - opacity: 1; - } -} - -@keyframes preview-overlay-out { - from { - opacity: 1; - } - to { - opacity: 0; - } -} - -.shape-preview-icon { - width: 14px; - height: 14px; - object-fit: contain; - filter: brightness(0) invert(1); -} - -[data-theme='light'] .shape-preview-icon { - filter: none; -} - -.select-with-hint { - display: flex; - flex-direction: column; - gap: 6px; -} - -.select-hint { - font-size: var(--font-xs); - color: var(--text-tertiary); -} - -.delete-btn { - width: 40px; - height: 40px; - border-radius: var(--radius-md); - border: none; - background: linear-gradient(135deg, var(--danger-primary), var(--danger-hover)); - display: grid; - place-items: center; - cursor: pointer; - transition: - transform var(--transition-fast), - box-shadow var(--transition-fast); -} - -.delete-btn:hover { - transform: translateY(-1px); - box-shadow: var(--shadow-sm); -} - -.delete-icon { - width: 18px; - height: 18px; - object-fit: contain; - filter: brightness(0) invert(1); -} - -.start-btn { - align-self: flex-end; - min-width: 200px; -} - -@media (max-width: 900px) { - .input-menu .menu-header { - flex-direction: column; - align-items: flex-start; - } - - .replacement-table-grid { - border-spacing: 0 var(--spacing-sm); - } - - .replacement-table-grid thead { - display: none; - } - - .replacement-table-grid tbody tr { - display: block; - } - - .replacement-table-grid tbody td { - display: block; - width: 100%; - border-left: 1px solid transparent; - border-right: 1px solid transparent; - border-radius: 0; - } - - .replacement-table-grid tbody td:first-child { - border-top-left-radius: var(--radius-md); - border-top-right-radius: var(--radius-md); - } - - .replacement-table-grid tbody td:last-child { - border-bottom-left-radius: var(--radius-md); - border-bottom-right-radius: var(--radius-md); - } - - .replacement-table-grid .cell-action { - text-align: right; - } - - .start-btn { - width: 100%; - } -} diff --git a/frontend/src/features/create-task/CreateTaskMenu.tsx b/frontend/src/features/create-task/CreateTaskMenu.tsx deleted file mode 100644 index 687129ce..00000000 --- a/frontend/src/features/create-task/CreateTaskMenu.tsx +++ /dev/null @@ -1,146 +0,0 @@ -import React from 'react'; -import type { CreateTaskMenuProps } from './types'; -import { getOptionDescription } from './utils'; -import { useCreateTask } from './hooks'; -import { - DataInputSection, - ImageReplacementPanel, - InputNotification, - MenuHeader, - PreviewModal, - SaveLocationSection, - StartButtonSection, - TemplateInputSection, - TextReplacementPanel, -} from './components'; -import './CreateTaskMenu.css'; - -const CreateTaskMenu: React.FC = ({ onStart }) => { - const task = useCreateTask({ onStart }); - - return ( -
- - - - - {/* File Inputs */} - - - - - {/* Replacement Tables - Separated */} -
- - - -
- - - - - - {task.previewShape && ( - - )} -
- ); -}; - -export default CreateTaskMenu; diff --git a/frontend/src/features/create-task/__tests__/CreateTaskMenu.test.tsx b/frontend/src/features/create-task/__tests__/CreateTaskMenu.test.tsx deleted file mode 100644 index 25c86987..00000000 --- a/frontend/src/features/create-task/__tests__/CreateTaskMenu.test.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import CreateTaskMenu from '../CreateTaskMenu'; -import * as backendApi from '@/shared/services/backendApi'; - -// Mocks -const createGroup = vi.fn(); -const tMock = (key: string) => key; - -vi.mock('@/shared/contexts/useApp', () => ({ - useApp: () => ({ t: tMock }), -})); - -vi.mock('@/shared/contexts/useJobs', () => ({ - useJobs: () => ({ createGroup }), -})); - -vi.mock('@/shared/services/backendApi', () => ({ - scanTemplate: vi.fn(), - loadFile: vi.fn(), - getAllColumns: vi.fn(), - getWorkbookInfo: vi.fn(), -})); - -// Mock window.electronAPI -const electronAPIMock = { - openFile: vi.fn(), - openFolder: vi.fn(), - saveFile: vi.fn(), - readSettings: vi.fn(), - writeSettings: vi.fn(), -}; -Object.assign(window, { electronAPI: electronAPIMock }); - -// Mock getAssetPath global -Object.assign(window, { getAssetPath: (...args: string[]) => args.join('/') }); - -describe('CreateTaskMenu', () => { - beforeEach(() => { - vi.clearAllMocks(); - sessionStorage.clear(); - }); - - it('renders correctly and handles file selection', async () => { - const user = userEvent.setup(); - render(); - - expect(screen.getByText('createTask.title')).toBeInTheDocument(); - - // Test PPTX selection - electronAPIMock.openFile.mockResolvedValueOnce('C:\\template.pptx'); - vi.mocked(backendApi.scanTemplate).mockResolvedValueOnce({ - type: 'scantemplate', - filePath: 'C:\\template.pptx', - shapes: [{ id: 1, name: 'Pic1', data: '', isImage: true }], - placeholders: ['{{Name}}'], - }); - - const pptxButton = screen.getAllByText('createTask.browse')[0]; - await user.click(pptxButton); - - await waitFor(() => { - expect(backendApi.scanTemplate).toHaveBeenCalledWith('C:\\template.pptx'); - }); - expect(screen.getByDisplayValue('C:\\template.pptx')).toBeInTheDocument(); - - // Test Data selection - electronAPIMock.openFile.mockResolvedValueOnce('C:\\data.xlsx'); - vi.mocked(backendApi.loadFile).mockResolvedValue({ - success: true, - num_sheets: 1, - sheets: ['Sheet1'], - group_id: 'g1', - file_type: 'sheet', - }); - vi.mocked(backendApi.getAllColumns).mockResolvedValue(['Name', 'Age']); - vi.mocked(backendApi.getWorkbookInfo).mockResolvedValue({ - Type: 'getworkbookinfo', - FilePath: 'C:\\data.xlsx', - Sheets: [{ Name: 'Sheet1', Headers: ['Name', 'Age'], RowCount: 10 }], - }); - - const dataButton = screen.getAllByText('createTask.browse')[1]; - await user.click(dataButton); - - await waitFor(() => { - expect(backendApi.getAllColumns).toHaveBeenCalled(); - }); - expect(screen.getByDisplayValue('C:\\data.xlsx')).toBeInTheDocument(); - }); - - it('validates inputs before starting', async () => { - const user = userEvent.setup(); - render(); - - const startBtn = screen.getByText('createTask.start'); - expect(startBtn).toBeDisabled(); - - // Simulate filled state via direct input changes to bypass file dialogs logic for speed - const inputs = screen.getAllByRole('textbox'); - await user.type(inputs[0], 'template.pptx'); - await user.type(inputs[1], 'data.xlsx'); - await user.type(inputs[2], 'C:\\output'); - - // Add a text replacement - // Need to trigger state updates that enable configuration - // This part is tricky without mocking the full load flow, so we rely on manual integration tests usually - // or we mock the state hydration. - }); -}); diff --git a/frontend/src/features/create-task/components/ActionSections.tsx b/frontend/src/features/create-task/components/ActionSections.tsx deleted file mode 100644 index 001abf7e..00000000 --- a/frontend/src/features/create-task/components/ActionSections.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import React from 'react'; -import type { SaveLocationSectionProps, StartButtonSectionProps } from '../types'; - -export const SaveLocationSection: React.FC = ({ - savePath, - onChangePath, - onBrowse, - t, -}) => ( -
- -
- onChangePath(e.target.value)} - placeholder={t('createTask.savePlaceholder')} - /> - -
-
-); - -export const StartButtonSection: React.FC = ({ - isStarting, - canStart, - onStart, - t, -}) => ( - -); diff --git a/frontend/src/features/create-task/components/DataInputSection.tsx b/frontend/src/features/create-task/components/DataInputSection.tsx deleted file mode 100644 index d75f4699..00000000 --- a/frontend/src/features/create-task/components/DataInputSection.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import React, { useEffect, useRef, useState } from 'react'; -import { getAssetPath } from '@/shared/utils/paths'; -import type { DataInputSectionProps } from '../types'; - -export const DataInputSection: React.FC = ({ - dataPath, - onChangePath, - onBrowse, - isLoadingColumns, - dataLoaded, - sheetCount, - uniqueColumnCount, - totalRows, - sheetNames, - selectedSheets, - sheetRowCounts, - allSheetsSelected, - someSheetsSelected, - onToggleAllSheets, - onToggleSheet, - t, -}) => { - const selectAllRef = useRef(null); - const [isSheetListOpen, setIsSheetListOpen] = useState(true); - const selectedRowCount = selectedSheets.reduce( - (sum, sheet) => sum + (sheetRowCounts[sheet] ?? 0), - 0, - ); - - useEffect(() => { - if (selectAllRef.current) { - selectAllRef.current.indeterminate = someSheetsSelected; - } - }, [someSheetsSelected]); - - return ( -
- -
- onChangePath(e.target.value)} - placeholder={t('createTask.dataPlaceholder')} - /> - -
- {dataLoaded && !isLoadingColumns && ( -
- {t('createTask.dataInfoLabel')} - - {t('createTask.sheetCount')}: {sheetCount} - - - {t('createTask.columnCount')}: {uniqueColumnCount} - - - {t('createTask.rowCount')}: {totalRows} - -
- )} - {dataLoaded && !isLoadingColumns && sheetNames.length > 0 && ( -
-
- - - - {t('createTask.sheetSelected')}: {selectedSheets.length}/{sheetNames.length} - -
-
- {sheetNames.map((sheet) => ( - - ))} -
-
- )} -
- ); -}; diff --git a/frontend/src/features/create-task/components/ImageReplacementPanel.tsx b/frontend/src/features/create-task/components/ImageReplacementPanel.tsx deleted file mode 100644 index 6ca70d3d..00000000 --- a/frontend/src/features/create-task/components/ImageReplacementPanel.tsx +++ /dev/null @@ -1,185 +0,0 @@ -import React from 'react'; -import { getAssetPath } from '@/shared/utils/paths'; -import ShapeSelector from '@/shared/components/ShapeSelector'; -import TagInput from '@/shared/components/TagInput'; -import type { ImageReplacementPanelProps } from '../types'; - -export const ImageReplacementPanel: React.FC = ({ - canConfigure, - showImageConfigs, - setShowImageConfigs, - addImageReplacement, - imageReplacements, - maxImageConfigs, - shapes, - getAvailableShapes, - updateImageReplacement, - removeImageReplacement, - roiOptions, - cropOptions, - getOptionDescription, - columns, - openPreview, - t, -}) => { - const isAtLimit = maxImageConfigs > 0 && imageReplacements.length >= maxImageConfigs; - - return ( -
-
-
- -
- -
-
-
-
-
{t('replacement.availableShapes')}
-
- {shapes.length === 0 ? ( -
{t('replacement.noShapes')}
- ) : ( - shapes.map((shape) => ( - - )) - )} -
-
- - - - - - - - - - - - - - - - - - - {imageReplacements.map((item) => ( - - - - - - - - ))} - -
{t('replacement.shape')}{t('replacement.column')}{t('replacement.roi')}{t('replacement.crop')}{t('replacement.delete')}
- updateImageReplacement(item.id, 'shapeId', shapeId)} - placeholder={t('replacement.shapePlaceholder')} - /> - - updateImageReplacement(item.id, 'columns', tags)} - suggestions={columns} - placeholder={t('replacement.columnPlaceholder')} - /> - -
- - - {getOptionDescription(roiOptions, item.roiType)} - -
-
-
- - - {getOptionDescription(cropOptions, item.cropType)} - -
-
- -
-
-
-
- ); -}; diff --git a/frontend/src/features/create-task/components/InputNotification.tsx b/frontend/src/features/create-task/components/InputNotification.tsx deleted file mode 100644 index 42cab37e..00000000 --- a/frontend/src/features/create-task/components/InputNotification.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import React from 'react'; -import { getAssetPath } from '@/shared/utils/paths'; -import type { InputNotificationProps } from '../types'; -import { splitNotificationText } from '../utils'; - -export const InputNotification: React.FC = ({ - notification, - isClosing, - onClose, - t, -}) => { - if (!notification) return null; - return ( -
- {(() => { - const { title, detail } = splitNotificationText(notification.text); - return ( - - {title} - {detail ? {detail} : null} - - ); - })()} - -
- ); -}; diff --git a/frontend/src/features/create-task/components/MenuHeader.tsx b/frontend/src/features/create-task/components/MenuHeader.tsx deleted file mode 100644 index ec779d72..00000000 --- a/frontend/src/features/create-task/components/MenuHeader.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import React from 'react'; -import { getAssetPath } from '@/shared/utils/paths'; -import type { MenuHeaderProps } from '../types'; - -export const MenuHeader: React.FC = ({ onImport, onExport, onClear, t }) => ( -
-

{t('createTask.title')}

-
- - - -
-
-); diff --git a/frontend/src/features/create-task/components/PreviewModal.tsx b/frontend/src/features/create-task/components/PreviewModal.tsx deleted file mode 100644 index 4257a66b..00000000 --- a/frontend/src/features/create-task/components/PreviewModal.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import React from 'react'; -import { getAssetPath } from '@/shared/utils/paths'; -import type { PreviewModalProps } from '../types'; - -export const PreviewModal: React.FC = ({ - previewShape, - previewClosing, - closePreview, - previewSize, - previewZoom, - previewOffset, - adjustPreviewZoom, - setPreviewZoom, - handleSavePreview, - togglePreviewZoom, - handlePreviewPointerDown, - handlePreviewPointerMove, - handlePreviewPointerUp, - handlePreviewWheel, - setPreviewSize, - dragMovedRef, - t, -}) => ( -
-
event.stopPropagation()} - > -
-
{t('createTask.previewTitle')}
- -
-
- {previewShape.name} - ID: {previewShape.id} - - {t('createTask.previewSize')}:{' '} - {previewSize ? `${previewSize.width}x${previewSize.height}px` : '...'} - -
-
- - - {t('createTask.previewZoom')}: {Math.round(previewZoom * 100)}% - - - - -
-
-
- {previewShape.name} 1 ? 'zoomed' : ''}`} - style={{ - transform: `translate(${previewOffset.x}px, ${previewOffset.y}px) scale(${previewZoom})`, - }} - onClick={() => { - if (!dragMovedRef.current) { - togglePreviewZoom(); - } - dragMovedRef.current = false; - }} - onPointerDown={handlePreviewPointerDown} - onPointerMove={handlePreviewPointerMove} - onPointerUp={handlePreviewPointerUp} - onPointerLeave={handlePreviewPointerUp} - onWheel={handlePreviewWheel} - draggable={false} - onLoad={(event) => { - const target = event.currentTarget; - setPreviewSize({ - width: target.naturalWidth, - height: target.naturalHeight, - }); - }} - /> -
-
-
-
-); diff --git a/frontend/src/features/create-task/components/TemplateInputSection.tsx b/frontend/src/features/create-task/components/TemplateInputSection.tsx deleted file mode 100644 index 96ffb48b..00000000 --- a/frontend/src/features/create-task/components/TemplateInputSection.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import React from 'react'; -import type { TemplateInputSectionProps } from '../types'; - -export const TemplateInputSection: React.FC = ({ - pptxPath, - onChangePath, - onBrowse, - isLoadingShapes, - isLoadingPlaceholders, - templateLoaded, - textShapeCount, - imageShapeCount, - t, -}) => ( -
- -
- onChangePath(e.target.value)} - placeholder={t('createTask.pptxPlaceholder')} - /> - -
- {templateLoaded && !isLoadingShapes && !isLoadingPlaceholders && ( -
- {t('createTask.templateInfoLabel')} - - {t('createTask.textShapeCount')}: {textShapeCount} - - - {t('createTask.imageShapeCount')}: {imageShapeCount} - -
- )} -
-); diff --git a/frontend/src/features/create-task/components/TextReplacementPanel.tsx b/frontend/src/features/create-task/components/TextReplacementPanel.tsx deleted file mode 100644 index c7d7996b..00000000 --- a/frontend/src/features/create-task/components/TextReplacementPanel.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import React from 'react'; -import { getAssetPath } from '@/shared/utils/paths'; -import TagInput from '@/shared/components/TagInput'; -import type { TextReplacementPanelProps } from '../types'; - -export const TextReplacementPanel: React.FC = ({ - canConfigure, - showTextConfigs, - setShowTextConfigs, - addTextReplacement, - textReplacements, - maxTextConfigs, - getAvailablePlaceholders, - updateTextReplacement, - removeTextReplacement, - isLoadingPlaceholders, - placeholders, - columns, - t, -}) => { - const isAtLimit = maxTextConfigs > 0 && textReplacements.length >= maxTextConfigs; - - return ( -
-
-
- -
- -
-
-
- - - - - - - - - - - - - - - {textReplacements.map((item) => { - const available = getAvailablePlaceholders(item.placeholder); - return ( - - - - - - ); - })} - -
{t('replacement.searchText')}{t('replacement.column')}{t('replacement.delete')}
- - - updateTextReplacement(item.id, 'columns', tags)} - suggestions={columns} - placeholder={t('replacement.columnPlaceholder')} - /> - - -
-
-
-
- ); -}; diff --git a/frontend/src/features/create-task/components/index.ts b/frontend/src/features/create-task/components/index.ts deleted file mode 100644 index fca309e9..00000000 --- a/frontend/src/features/create-task/components/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export { InputNotification } from './InputNotification'; -export { TextReplacementPanel } from './TextReplacementPanel'; -export { ImageReplacementPanel } from './ImageReplacementPanel'; -export { PreviewModal } from './PreviewModal'; -export { MenuHeader } from './MenuHeader'; -export { TemplateInputSection } from './TemplateInputSection'; -export { DataInputSection } from './DataInputSection'; -export { SaveLocationSection, StartButtonSection } from './ActionSections'; diff --git a/frontend/src/features/create-task/hooks/index.ts b/frontend/src/features/create-task/hooks/index.ts deleted file mode 100644 index 00e3b44a..00000000 --- a/frontend/src/features/create-task/hooks/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { useNotification, type UseNotificationOptions } from './useNotification'; -export { usePreview } from './usePreview'; -export { useReplacements } from './useReplacements'; -export { useCreateTask, type UseCreateTaskOptions } from './useCreateTask'; diff --git a/frontend/src/features/create-task/hooks/useCreateTask.ts b/frontend/src/features/create-task/hooks/useCreateTask.ts deleted file mode 100644 index 3bf5af6e..00000000 --- a/frontend/src/features/create-task/hooks/useCreateTask.ts +++ /dev/null @@ -1,937 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useApp } from '@/shared/contexts/useApp'; -import { useJobs } from '@/shared/contexts/useJobs'; -import * as backendApi from '@/shared/services/backendApi'; -import { getAssetPath } from '@/shared/utils/paths'; -import type { CropOption, RoiOption, SavedInputState, Shape } from '../types'; -import { - buildImageConfigs, - buildSheetInfo, - buildTextConfigs, - computeValidationState, - getOptionDescription, - loadDataAssets, - loadSavedState, - loadTemplateAssets, - mapImageReplacements, - mapTextReplacements, - normalizeSheetNames, - normalizeSheetRowCounts, - resolveAvailablePlaceholders, - resolveAvailableShapes, - resolveRequestedSheets, - resolvePath, - STORAGE_KEYS, -} from '../utils'; -import { useNotification } from './useNotification'; -import { usePreview } from './usePreview'; -import { useReplacements } from './useReplacements'; - -/** - * Options for the useCreateTask hook. - */ -export interface UseCreateTaskOptions { - /** Callback invoked when slide generation starts */ - onStart: () => void; -} - -/** - * Hook for managing the slide generation task creation workflow. - * - * @remarks - * This hook orchestrates the entire task creation process including: - * - Template file selection and scanning - * - Data file loading and sheet selection - * - Text and image replacement configuration - * - Preview generation - * - Job submission - * - * @param options - Hook configuration options - * @returns State and handlers for the task creation form - * - * @example - * ```tsx - * const { - * pptxPath, setPptxPath, - * dataPath, setDataPath, - * handleSubmit, - * validationState - * } = useCreateTask({ onStart: () => navigate('/process') }); - * ``` - */ -export const useCreateTask = ({ onStart }: UseCreateTaskOptions) => { - const { t } = useApp(); - const { createGroup } = useJobs(); - - // Notification and preview hooks - const notificationHook = useNotification({ t }); - const previewHook = usePreview(); - const replacementsHook = useReplacements(); - - // ROI and Crop options - const roiOptions: RoiOption[] = useMemo( - () => [ - { - value: 'RuleOfThirds', - label: t('replacement.roiRuleOfThirds'), - description: t('replacement.roiRuleOfThirdsDesc'), - }, - { - value: 'Prominent', - label: t('replacement.roiProminent'), - description: t('replacement.roiProminentDesc'), - }, - { - value: 'Center', - label: t('replacement.roiCenter'), - description: t('replacement.roiCenterDesc'), - }, - ], - [t], - ); - - const cropOptions: CropOption[] = useMemo( - () => [ - { - value: 'Crop', - label: t('replacement.cropCrop'), - description: t('replacement.cropCropDesc'), - }, - { - value: 'Fit', - label: t('replacement.cropFit'), - description: t('replacement.cropFitDesc'), - }, - ], - [t], - ); - - // Load saved state - const savedState = useMemo(() => loadSavedState(), []); - - // Path state - const [pptxPath, setPptxPath] = useState(savedState?.pptxPath || ''); - const [dataPath, setDataPath] = useState(savedState?.dataPath || ''); - const [savePath, setSavePath] = useState(savedState?.savePath || ''); - - // Data state - const [columns, setColumns] = useState(savedState?.columns || []); - const initialSheetNames = normalizeSheetNames(savedState?.sheetNames); - const [sheetNames, setSheetNames] = useState(initialSheetNames); - const [selectedSheets, setSelectedSheets] = useState( - normalizeSheetNames(savedState?.selectedSheets ?? initialSheetNames), - ); - const [sheetRowCounts, setSheetRowCounts] = useState>( - normalizeSheetRowCounts(savedState?.sheetRowCounts), - ); - const [shapes, setShapes] = useState(savedState?.shapes || []); - const [placeholders, setPlaceholders] = useState(savedState?.placeholders || []); - const [sheetCount, setSheetCount] = useState(savedState?.sheetCount || 0); - const [totalRows, setTotalRows] = useState(savedState?.totalRows || 0); - - // Loading states - const [isLoadingColumns, setIsLoadingColumns] = useState(false); - const [isLoadingShapes, setIsLoadingShapes] = useState(false); - const [isLoadingPlaceholders, setIsLoadingPlaceholders] = useState(false); - const [isStarting, setIsStarting] = useState(false); - - // Loaded states - const [templateLoaded, setTemplateLoaded] = useState(Boolean(savedState?.templateLoaded)); - const [dataLoaded, setDataLoaded] = useState(Boolean(savedState?.dataLoaded)); - - // Refs - const isHydratingRef = useRef(false); - const hasHydratedRef = useRef(false); - const templateErrorAtRef = useRef(0); - const pptxPathRef = useRef(pptxPath); - const dataErrorAtRef = useRef(0); - const dataPathRef = useRef(dataPath); - const lastLoadedDataPathRef = useRef(savedState?.dataLoaded ? savedState?.dataPath || '' : ''); - const lastLoadedTemplatePathRef = useRef( - savedState?.templateLoaded ? savedState?.pptxPath || '' : '', - ); - - // Initialize replacements from saved state - useEffect(() => { - if (savedState?.textReplacements) { - replacementsHook.setTextReplacements( - savedState.textReplacements.map((item) => ({ - id: item.id ?? 1, - placeholder: item.placeholder || item.searchText || '', - columns: item.columns || [], - })), - ); - } - if (savedState?.imageReplacements) { - replacementsHook.setImageReplacements( - savedState.imageReplacements.map((item) => ({ - id: item.id ?? 1, - shapeId: item.shapeId ?? '', - columns: item.columns ?? [], - roiType: item.roiType ?? 'RuleOfThirds', - cropType: item.cropType ?? 'Fit', - })), - ); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - // Clear legacy config - useEffect(() => { - localStorage.removeItem('config'); - }, []); - - // Save state to sessionStorage whenever it changes - useEffect(() => { - const state = { - pptxPath, - dataPath, - savePath, - textReplacements: replacementsHook.textReplacements, - imageReplacements: replacementsHook.imageReplacements, - shapes, - placeholders, - columns, - sheetNames, - selectedSheets, - sheetRowCounts, - sheetCount, - totalRows, - templateLoaded, - dataLoaded, - }; - sessionStorage.setItem(STORAGE_KEYS.inputMenuState, JSON.stringify(state)); - }, [ - pptxPath, - dataPath, - savePath, - replacementsHook.textReplacements, - replacementsHook.imageReplacements, - shapes, - placeholders, - columns, - sheetNames, - selectedSheets, - sheetRowCounts, - sheetCount, - totalRows, - templateLoaded, - dataLoaded, - ]); - - // Keep refs in sync - useEffect(() => { - pptxPathRef.current = pptxPath; - }, [pptxPath]); - - useEffect(() => { - dataPathRef.current = dataPath; - }, [dataPath]); - - // Notification helpers - const { showNotification, formatErrorMessage } = notificationHook; - const { setTextReplacements, setImageReplacements, clearReplacements } = replacementsHook; - - const notifyTemplateError = useCallback( - (error: unknown) => { - const now = Date.now(); - if (now - templateErrorAtRef.current < 800) return; - templateErrorAtRef.current = now; - showNotification('error', formatErrorMessage('createTask.templateLoadError', error)); - }, - [showNotification, formatErrorMessage], - ); - - const notifyDataError = useCallback( - (error: unknown) => { - const now = Date.now(); - if (now - dataErrorAtRef.current < 800) return; - dataErrorAtRef.current = now; - showNotification('error', formatErrorMessage('createTask.columnLoadError', error)); - }, - [showNotification, formatErrorMessage], - ); - - // Load template from server - const loadTemplateFromServer = useCallback( - async (filePath: string) => { - if (!filePath) { - setShapes([]); - setPlaceholders([]); - setTemplateLoaded(false); - return; - } - setIsLoadingShapes(true); - setIsLoadingPlaceholders(true); - try { - const response = await backendApi.scanTemplate(filePath); - const data = response as backendApi.SlideScanTemplateSuccess; - const mappedShapes = (data.shapes ?? []) - .filter((shape) => shape.isImage === true) - .map((shape) => ({ - id: String(shape.id), - name: shape.name, - preview: shape.data - ? `data:image/png;base64,${shape.data}` - : getAssetPath('images', 'app-icon.png'), - })); - setShapes(mappedShapes); - lastLoadedTemplatePathRef.current = filePath; - - const items = (data.placeholders ?? []) - .map((item) => item.trim()) - .filter((item) => item.length > 0); - const unique = Array.from(new Set(items)); - unique.sort((a, b) => a.localeCompare(b)); - setPlaceholders(unique); - setTemplateLoaded(true); - } catch (error) { - if (!isHydratingRef.current && filePath === pptxPathRef.current) { - notifyTemplateError(error); - setPptxPath(''); - setShapes([]); - setPlaceholders([]); - setTemplateLoaded(false); - } - } finally { - setIsLoadingShapes(false); - setIsLoadingPlaceholders(false); - } - }, - [notifyTemplateError], - ); - - // Load data from server - const loadDataFromServer = useCallback( - async (filePath: string) => { - if (!filePath) { - setColumns([]); - setSheetNames([]); - setSelectedSheets([]); - setSheetRowCounts({}); - setSheetCount(0); - setTotalRows(0); - setDataLoaded(false); - return; - } - - setIsLoadingColumns(true); - try { - await backendApi.loadFile(filePath); - const allColumns = await backendApi.getAllColumns([filePath]); - const workbookInfo = await backendApi.getWorkbookInfo(filePath); - const workbookData = workbookInfo as backendApi.SheetWorkbookGetInfoSuccess; - const sheetsInfo = workbookData.Sheets ?? []; - const rowsSum = sheetsInfo.reduce((acc, sheet) => acc + (sheet.RowCount ?? 0), 0); - const sheetInfo = buildSheetInfo(sheetsInfo); - - setColumns(allColumns); - setSheetNames(sheetInfo.sheetNames); - setSelectedSheets(sheetInfo.sheetNames); - setSheetRowCounts(normalizeSheetRowCounts(sheetInfo.sheetRowCounts)); - setSheetCount(sheetsInfo.length); - setTotalRows(rowsSum); - setDataLoaded(true); - lastLoadedDataPathRef.current = filePath; - } catch (error) { - if (!isHydratingRef.current && filePath === dataPathRef.current) { - notifyDataError(error); - setDataPath(''); - setColumns([]); - setSheetNames([]); - setSelectedSheets([]); - setSheetRowCounts({}); - setSheetCount(0); - setTotalRows(0); - setDataLoaded(false); - } - } finally { - setIsLoadingColumns(false); - } - }, - [notifyDataError], - ); - - // Hydrate from saved state - const hydrateFromSavedState = useCallback(async () => { - if (!savedState) return; - if (hasHydratedRef.current) return; - hasHydratedRef.current = true; - isHydratingRef.current = true; - - const nextPptxPath = savedState.pptxPath || ''; - const nextDataPath = savedState.dataPath || ''; - const nextSavePath = savedState.savePath || ''; - - setPptxPath(nextPptxPath); - setDataPath(nextDataPath); - setSavePath(nextSavePath); - - const cached = { - shapes: savedState.shapes || [], - placeholders: savedState.placeholders || [], - columns: savedState.columns || [], - sheetNames: normalizeSheetNames(savedState.sheetNames), - selectedSheets: normalizeSheetNames(savedState.selectedSheets ?? savedState.sheetNames), - sheetRowCounts: normalizeSheetRowCounts(savedState.sheetRowCounts), - sheetCount: savedState.sheetCount || 0, - totalRows: savedState.totalRows || 0, - templateLoaded: savedState.templateLoaded || false, - dataLoaded: savedState.dataLoaded || false, - }; - - setShapes(cached.shapes); - setPlaceholders(cached.placeholders); - setColumns(cached.columns); - setSheetNames(cached.sheetNames); - setSelectedSheets(cached.selectedSheets); - setSheetRowCounts(cached.sheetRowCounts); - setSheetCount(cached.sheetCount); - setTotalRows(cached.totalRows); - setTemplateLoaded(cached.templateLoaded); - setDataLoaded(cached.dataLoaded); - lastLoadedTemplatePathRef.current = cached.templateLoaded ? nextPptxPath : ''; - lastLoadedDataPathRef.current = cached.dataLoaded ? nextDataPath : ''; - - setIsLoadingShapes(true); - setIsLoadingPlaceholders(true); - setIsLoadingColumns(true); - - try { - const templateAssets = - !cached.templateLoaded && nextPptxPath - ? await loadTemplateAssets(nextPptxPath) - : { shapes: cached.shapes, placeholders: cached.placeholders }; - const dataAssets = - !cached.dataLoaded && nextDataPath - ? await loadDataAssets(nextDataPath) - : { - columns: cached.columns, - sheetNames: cached.sheetNames, - sheetRowCounts: cached.sheetRowCounts, - sheetCount: cached.sheetCount, - totalRows: cached.totalRows, - }; - - if (!cached.templateLoaded && nextPptxPath) { - setTemplateLoaded(true); - lastLoadedTemplatePathRef.current = nextPptxPath; - } - if (!cached.dataLoaded && nextDataPath) { - setSheetCount(dataAssets.sheetCount); - setTotalRows(dataAssets.totalRows); - setDataLoaded(true); - lastLoadedDataPathRef.current = nextDataPath; - } - - const explicitSelectedSheets = Array.isArray(savedState.selectedSheets) - ? savedState.selectedSheets - : undefined; - const requestedSheets = (explicitSelectedSheets ?? savedState.sheetNames ?? []).filter( - (name): name is string => typeof name === 'string', - ); - const availableSheets = dataAssets.sheetNames ?? []; - const resolvedSelection = resolveRequestedSheets( - availableSheets, - requestedSheets, - !explicitSelectedSheets, - ); - - const filteredText = mapTextReplacements( - savedState, - templateAssets.placeholders, - dataAssets.columns, - ); - const filteredImages = mapImageReplacements( - savedState, - templateAssets.shapes, - dataAssets.columns, - ); - - setShapes(templateAssets.shapes); - setPlaceholders(templateAssets.placeholders); - setColumns(dataAssets.columns); - setSheetNames(availableSheets); - setSelectedSheets(resolvedSelection); - setSheetRowCounts(normalizeSheetRowCounts(dataAssets.sheetRowCounts)); - setTextReplacements(filteredText); - setImageReplacements(filteredImages); - } catch (error) { - showNotification('error', formatErrorMessage('createTask.restoreError', error)); - } finally { - setIsLoadingShapes(false); - setIsLoadingPlaceholders(false); - setIsLoadingColumns(false); - isHydratingRef.current = false; - } - }, [savedState, showNotification, formatErrorMessage, setTextReplacements, setImageReplacements]); - - // Run hydration on mount - useEffect(() => { - hydrateFromSavedState().catch(() => undefined); - }, [hydrateFromSavedState]); - - // Schedule template load when pptxPath changes - useEffect(() => { - if (isHydratingRef.current) return; - if (!pptxPath) { - setShapes([]); - setPlaceholders([]); - setTemplateLoaded(false); - return; - } - - if (templateLoaded && lastLoadedTemplatePathRef.current === pptxPath && shapes.length > 0) { - return; - } - - setShapes([]); - setPlaceholders([]); - setTemplateLoaded(false); - - const timer = setTimeout(() => { - loadTemplateFromServer(pptxPath).catch(() => undefined); - }, 400); - - return () => clearTimeout(timer); - }, [pptxPath, templateLoaded, shapes.length, loadTemplateFromServer]); - - // Schedule data load when dataPath changes - useEffect(() => { - if (isHydratingRef.current) return; - if (!dataPath) { - setColumns([]); - setSheetNames([]); - setSelectedSheets([]); - setSheetRowCounts({}); - setSheetCount(0); - setTotalRows(0); - setDataLoaded(false); - return; - } - - if (isLoadingColumns || (dataLoaded && lastLoadedDataPathRef.current === dataPath)) { - return; - } - - setColumns([]); - setSheetNames([]); - setSelectedSheets([]); - setSheetRowCounts({}); - setSheetCount(0); - setTotalRows(0); - setDataLoaded(false); - - const timer = setTimeout(() => { - loadDataFromServer(dataPath).catch(() => undefined); - }, 400); - - return () => clearTimeout(timer); - }, [dataPath, dataLoaded, isLoadingColumns, loadDataFromServer]); - - // Browse handlers - const handleBrowsePptx = useCallback(async () => { - const path = await window.electronAPI.openFile([ - { name: 'PowerPoint Files', extensions: ['pptx', 'potx'] }, - ]); - if (path) { - setPptxPath(path); - setShapes([]); - setPlaceholders([]); - } - }, []); - - const handleBrowseData = useCallback(async () => { - const path = await window.electronAPI.openFile([ - { name: 'Spreadsheets Files', extensions: ['xlsx', 'xlsm'] }, - ]); - if (path) { - setDataPath(path); - } - }, []); - - const handleBrowseSave = useCallback(async () => { - const path = await window.electronAPI.openFolder(); - if (path) setSavePath(path); - }, []); - - // Replacement config limits - const maxTextConfigs = Math.min(placeholders.length, columns.length); - const maxImageConfigs = Math.min(shapes.length, columns.length); - - // Validation state - const validationState = useMemo( - () => - computeValidationState({ - pptxPath, - dataPath, - savePath, - isLoadingColumns, - isLoadingShapes, - isLoadingPlaceholders, - placeholders, - shapes, - columns, - textReplacements: replacementsHook.textReplacements, - imageReplacements: replacementsHook.imageReplacements, - sheetNames, - selectedSheets, - sheetRowCounts, - }), - [ - pptxPath, - dataPath, - savePath, - isLoadingColumns, - isLoadingShapes, - isLoadingPlaceholders, - placeholders, - shapes, - columns, - replacementsHook.textReplacements, - replacementsHook.imageReplacements, - sheetNames, - selectedSheets, - sheetRowCounts, - ], - ); - - // Sheet selection - const allSheetsSelected = sheetNames.length > 0 && selectedSheets.length === sheetNames.length; - const someSheetsSelected = selectedSheets.length > 0 && selectedSheets.length < sheetNames.length; - - const toggleAllSheets = useCallback(() => { - setSelectedSheets(allSheetsSelected ? [] : sheetNames); - }, [allSheetsSelected, sheetNames]); - - const toggleSheet = useCallback( - (sheetName: string) => { - setSelectedSheets((prev) => { - const next = new Set(prev); - if (next.has(sheetName)) { - next.delete(sheetName); - } else { - next.add(sheetName); - } - return sheetNames.filter((name) => next.has(name)); - }); - }, - [sheetNames], - ); - - // Placeholder and shape availability helpers - const getAvailablePlaceholders = useCallback( - (current: string) => - resolveAvailablePlaceholders(replacementsHook.textReplacements, placeholders, current), - [replacementsHook.textReplacements, placeholders], - ); - - const getAvailableShapes = useCallback( - (current: string) => - resolveAvailableShapes(replacementsHook.imageReplacements, shapes, current), - [replacementsHook.imageReplacements, shapes], - ); - - // Start job - const handleStart = useCallback(async () => { - const resolvedPptxPath = resolvePath(pptxPath); - const resolvedDataPath = resolvePath(dataPath); - const resolvedSavePath = resolvePath(savePath); - - if (!resolvedPptxPath || !resolvedDataPath || !resolvedSavePath || !validationState.canStart) { - showNotification('error', t('createTask.error')); - return; - } - - const textConfigs = buildTextConfigs(replacementsHook.textReplacements); - const imageConfigs = buildImageConfigs(replacementsHook.imageReplacements); - - setPptxPath(resolvedPptxPath); - setDataPath(resolvedDataPath); - setSavePath(resolvedSavePath); - - try { - setIsStarting(true); - await createGroup({ - templatePath: resolvedPptxPath, - spreadsheetPath: resolvedDataPath, - outputPath: resolvedSavePath, - textConfigs, - imageConfigs, - sheetNames: selectedSheets.length > 0 ? selectedSheets : undefined, - }); - onStart(); - } catch (error) { - console.error('Failed to start job:', error); - const message = error instanceof Error ? error.message : t('createTask.error'); - showNotification('error', message); - } finally { - setIsStarting(false); - } - }, [ - pptxPath, - dataPath, - savePath, - validationState.canStart, - replacementsHook.textReplacements, - replacementsHook.imageReplacements, - selectedSheets, - createGroup, - onStart, - showNotification, - t, - ]); - - // Export config - const exportConfig = useCallback(async () => { - const config = { - pptxPath, - dataPath, - savePath, - selectedSheets, - textReplacements: replacementsHook.textReplacements, - imageReplacements: replacementsHook.imageReplacements, - }; - - const path = await window.electronAPI.saveFile([ - { name: 'JSON Files', extensions: ['json'] }, - { name: 'All Files', extensions: ['*'] }, - ]); - - if (!path) return; - - try { - await window.electronAPI.writeSettings(path, JSON.stringify(config, null, 2)); - showNotification('success', t('createTask.exportSuccess')); - } catch { - showNotification('error', t('createTask.exportError')); - } - }, [ - pptxPath, - dataPath, - savePath, - selectedSheets, - replacementsHook.textReplacements, - replacementsHook.imageReplacements, - showNotification, - t, - ]); - - // Import config - const importConfig = useCallback(async () => { - const path = await window.electronAPI.openFile([ - { name: 'JSON Files', extensions: ['json'] }, - { name: 'All Files', extensions: ['*'] }, - ]); - - if (!path) return; - - setIsLoadingShapes(true); - setIsLoadingPlaceholders(true); - setIsLoadingColumns(true); - - try { - const data = await window.electronAPI.readSettings(path); - if (!data) return; - - const config = JSON.parse(data) as SavedInputState; - const nextPptxPath = config.pptxPath || ''; - const nextDataPath = config.dataPath || ''; - const nextSavePath = config.savePath || ''; - - setPptxPath(nextPptxPath); - setDataPath(nextDataPath); - setSavePath(nextSavePath); - setShapes([]); - setPlaceholders([]); - setColumns([]); - setSheetNames([]); - setSelectedSheets([]); - setSheetRowCounts({}); - setSheetCount(0); - setTotalRows(0); - setTemplateLoaded(false); - setDataLoaded(false); - clearReplacements(); - - const templateAssets = nextPptxPath - ? await loadTemplateAssets(nextPptxPath) - : { shapes: [], placeholders: [] }; - const dataAssets = nextDataPath - ? await loadDataAssets(nextDataPath) - : { - columns: [], - sheetCount: 0, - totalRows: 0, - sheetNames: [], - sheetRowCounts: {}, - }; - - if (nextPptxPath) { - setTemplateLoaded(true); - lastLoadedTemplatePathRef.current = nextPptxPath; - } - if (nextDataPath) { - const explicitSelectedSheets = Array.isArray(config.selectedSheets) - ? config.selectedSheets - : undefined; - const requestedSheets = (explicitSelectedSheets ?? config.sheetNames ?? []).filter( - (name): name is string => typeof name === 'string', - ); - const availableSheets = dataAssets.sheetNames; - const resolvedSelection = resolveRequestedSheets( - availableSheets, - requestedSheets, - !explicitSelectedSheets, - ); - setSheetNames(availableSheets); - setSelectedSheets(resolvedSelection); - setSheetRowCounts(normalizeSheetRowCounts(dataAssets.sheetRowCounts)); - setSheetCount(dataAssets.sheetCount); - setTotalRows(dataAssets.totalRows); - setDataLoaded(true); - lastLoadedDataPathRef.current = nextDataPath; - } - - const filteredText = mapTextReplacements( - { textReplacements: config.textReplacements }, - templateAssets.placeholders, - dataAssets.columns, - ); - const filteredImages = mapImageReplacements( - { imageReplacements: config.imageReplacements }, - templateAssets.shapes, - dataAssets.columns, - ); - - setShapes(templateAssets.shapes); - setPlaceholders(templateAssets.placeholders); - setColumns(dataAssets.columns); - setTextReplacements(filteredText); - setImageReplacements(filteredImages); - if (!nextDataPath) { - setSheetNames([]); - setSelectedSheets([]); - setSheetRowCounts({}); - } - showNotification('success', t('createTask.importSuccess')); - } catch { - showNotification('error', t('createTask.importError')); - } finally { - setIsLoadingShapes(false); - setIsLoadingPlaceholders(false); - setIsLoadingColumns(false); - } - }, [showNotification, setTextReplacements, setImageReplacements, clearReplacements, t]); - - // Clear all - const clearAll = useCallback(() => { - if (confirm(t('createTask.confirmClear') || 'Are you sure you want to clear all data?')) { - setPptxPath(''); - setDataPath(''); - setSavePath(''); - setColumns([]); - setSheetNames([]); - setSelectedSheets([]); - setSheetRowCounts({}); - setShapes([]); - setPlaceholders([]); - setSheetCount(0); - setTotalRows(0); - setTemplateLoaded(false); - setDataLoaded(false); - clearReplacements(); - lastLoadedTemplatePathRef.current = ''; - lastLoadedDataPathRef.current = ''; - sessionStorage.removeItem(STORAGE_KEYS.inputMenuState); - } - }, [t, clearReplacements]); - - return { - // Translation - t, - - // Options - roiOptions, - cropOptions, - getOptionDescription, - - // Path state - pptxPath, - dataPath, - savePath, - setPptxPath, - setDataPath, - setSavePath, - - // Data state - columns, - sheetNames, - selectedSheets, - sheetRowCounts, - shapes, - placeholders, - sheetCount, - totalRows, - - // Loading states - isLoadingColumns, - isLoadingShapes, - isLoadingPlaceholders, - isStarting, - - // Loaded states - templateLoaded, - dataLoaded, - - // Validation - ...validationState, - - // Sheet selection - allSheetsSelected, - someSheetsSelected, - toggleAllSheets, - toggleSheet, - - // Config limits - maxTextConfigs, - maxImageConfigs, - - // Placeholder/shape availability - getAvailablePlaceholders, - getAvailableShapes, - - // Replacements hook - textReplacements: replacementsHook.textReplacements, - imageReplacements: replacementsHook.imageReplacements, - showTextConfigs: replacementsHook.showTextConfigs, - showImageConfigs: replacementsHook.showImageConfigs, - setShowTextConfigs: replacementsHook.setShowTextConfigs, - setShowImageConfigs: replacementsHook.setShowImageConfigs, - addTextReplacement: () => replacementsHook.addTextReplacement(maxTextConfigs), - removeTextReplacement: replacementsHook.removeTextReplacement, - updateTextReplacement: replacementsHook.updateTextReplacement, - addImageReplacement: () => replacementsHook.addImageReplacement(maxImageConfigs), - removeImageReplacement: replacementsHook.removeImageReplacement, - updateImageReplacement: replacementsHook.updateImageReplacement, - - // Browse handlers - handleBrowsePptx, - handleBrowseData, - handleBrowseSave, - - // Actions - handleStart, - exportConfig, - importConfig, - clearAll, - - // Notification - notification: notificationHook.notification, - isNotificationClosing: notificationHook.isNotificationClosing, - hideNotification: notificationHook.hideNotification, - - // Preview - ...previewHook, - }; -}; diff --git a/frontend/src/features/create-task/hooks/useNotification.ts b/frontend/src/features/create-task/hooks/useNotification.ts deleted file mode 100644 index 890a5a29..00000000 --- a/frontend/src/features/create-task/hooks/useNotification.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { useCallback, useMemo, useRef, useState } from 'react'; -import type { NotificationState } from '../types'; -import { getErrorDetail } from '../utils'; - -/** - * Options for the useNotification hook. - */ -export interface UseNotificationOptions { - /** Translation function for notification messages */ - t: (key: string) => string; -} - -/** - * Hook for managing toast notifications with auto-hide behavior. - * - * @remarks - * Provides notification display management including: - * - Success and error notifications - * - Automatic dismissal with configurable timing - * - Animated close transitions - * - * @param options - Hook configuration - * @returns Notification state and handlers - * - * @example - * ```tsx - * const { notification, showNotification, hideNotification } = useNotification({ t }); - * showNotification('success', 'Task created successfully'); - * ``` - */ -export const useNotification = ({ t }: UseNotificationOptions) => { - const [notification, setNotification] = useState(null); - const [isNotificationClosing, setIsNotificationClosing] = useState(false); - const notificationHideTimeoutRef = useRef(null); - const notificationCloseTimeoutRef = useRef(null); - - const formatErrorMessage = useCallback( - (key: string, error: unknown): string => { - const detail = getErrorDetail(error); - return detail ? `${t(key)}: ${detail}` : t(key); - }, - [t], - ); - - const clearNotificationTimeouts = useCallback(() => { - if (notificationHideTimeoutRef.current) { - window.clearTimeout(notificationHideTimeoutRef.current); - notificationHideTimeoutRef.current = null; - } - if (notificationCloseTimeoutRef.current) { - window.clearTimeout(notificationCloseTimeoutRef.current); - notificationCloseTimeoutRef.current = null; - } - }, []); - - const hideNotification = useCallback(() => { - clearNotificationTimeouts(); - setIsNotificationClosing(true); - notificationCloseTimeoutRef.current = window.setTimeout(() => { - setNotification(null); - setIsNotificationClosing(false); - notificationCloseTimeoutRef.current = null; - }, 180); - }, [clearNotificationTimeouts]); - - const showNotification = useCallback( - (type: 'success' | 'error', text: string) => { - clearNotificationTimeouts(); - setNotification({ type, text }); - setIsNotificationClosing(false); - notificationHideTimeoutRef.current = window.setTimeout(() => { - hideNotification(); - notificationHideTimeoutRef.current = null; - }, 4000); - }, - [clearNotificationTimeouts, hideNotification], - ); - - return useMemo( - () => ({ - notification, - isNotificationClosing, - showNotification, - hideNotification, - formatErrorMessage, - }), - [notification, isNotificationClosing, showNotification, hideNotification, formatErrorMessage], - ); -}; diff --git a/frontend/src/features/create-task/hooks/usePreview.ts b/frontend/src/features/create-task/hooks/usePreview.ts deleted file mode 100644 index 57a9e388..00000000 --- a/frontend/src/features/create-task/hooks/usePreview.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { useCallback, useMemo, useRef, useState } from 'react'; -import type { Shape } from '../types'; - -/** - * Hook for managing shape preview modal with zoom and pan support. - * - * @remarks - * Provides preview functionality for template shapes including: - * - Modal open/close with animations - * - Zoom in/out with mouse wheel - * - Pan support when zoomed - * - Save preview image to file - * - * @returns Preview state and interaction handlers - * - * @example - * ```tsx - * const { - * previewShape, - * openPreview, - * closePreview, - * previewZoom - * } = usePreview(); - * ``` - */ -export const usePreview = () => { - const [previewShape, setPreviewShape] = useState(null); - const [previewClosing, setPreviewClosing] = useState(false); - const [previewZoom, setPreviewZoom] = useState(1); - const [previewSize, setPreviewSize] = useState<{ width: number; height: number } | null>(null); - const [previewOffset, setPreviewOffset] = useState({ x: 0, y: 0 }); - const isDraggingRef = useRef(false); - const dragStartRef = useRef({ x: 0, y: 0 }); - const dragMovedRef = useRef(false); - - const openPreview = useCallback((shape: Shape) => { - setPreviewShape(shape); - setPreviewClosing(false); - setPreviewZoom(1); - setPreviewSize(null); - setPreviewOffset({ x: 0, y: 0 }); - }, []); - - const closePreview = useCallback(() => { - setPreviewClosing(true); - setTimeout(() => { - setPreviewShape(null); - setPreviewClosing(false); - }, 180); - }, []); - - const adjustPreviewZoom = useCallback((delta: number) => { - setPreviewZoom((prev) => { - const next = Math.min(3, Math.max(0.5, Number((prev + delta).toFixed(2)))); - if (next === 1) { - setPreviewOffset({ x: 0, y: 0 }); - } - return next; - }); - }, []); - - const togglePreviewZoom = useCallback(() => { - setPreviewZoom((prev) => { - const next = prev === 1 ? 2 : 1; - if (next === 1) { - setPreviewOffset({ x: 0, y: 0 }); - } - return next; - }); - }, []); - - const handlePreviewPointerDown = useCallback( - (event: React.PointerEvent) => { - if (previewZoom <= 1) return; - if (event.button !== 0) return; - isDraggingRef.current = true; - dragMovedRef.current = false; - dragStartRef.current = { - x: event.clientX - previewOffset.x, - y: event.clientY - previewOffset.y, - }; - event.currentTarget.setPointerCapture(event.pointerId); - }, - [previewZoom, previewOffset], - ); - - const handlePreviewPointerMove = useCallback( - (event: React.PointerEvent) => { - if (!isDraggingRef.current) return; - const nextX = event.clientX - dragStartRef.current.x; - const nextY = event.clientY - dragStartRef.current.y; - if (!dragMovedRef.current) { - const dx = Math.abs(nextX - previewOffset.x); - const dy = Math.abs(nextY - previewOffset.y); - if (dx > 2 || dy > 2) { - dragMovedRef.current = true; - } - } - setPreviewOffset({ x: nextX, y: nextY }); - }, - [previewOffset], - ); - - const handlePreviewPointerUp = useCallback((event: React.PointerEvent) => { - isDraggingRef.current = false; - event.currentTarget.releasePointerCapture(event.pointerId); - }, []); - - const handlePreviewWheel = useCallback( - (event: React.WheelEvent) => { - event.preventDefault(); - const delta = event.deltaY > 0 ? -0.1 : 0.1; - adjustPreviewZoom(delta); - }, - [adjustPreviewZoom], - ); - - const handleSavePreview = useCallback(async () => { - if (!previewShape) return; - try { - const response = await fetch(previewShape.preview); - const blob = await response.blob(); - const url = URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.download = `${previewShape.name || 'shape'}.png`; - document.body.appendChild(link); - link.click(); - link.remove(); - URL.revokeObjectURL(url); - } catch (error) { - console.error('Failed to save preview image:', error); - } - }, [previewShape]); - - return useMemo( - () => ({ - previewShape, - previewClosing, - previewZoom, - previewSize, - previewOffset, - dragMovedRef, - openPreview, - closePreview, - adjustPreviewZoom, - setPreviewZoom, - setPreviewSize, - togglePreviewZoom, - handlePreviewPointerDown, - handlePreviewPointerMove, - handlePreviewPointerUp, - handlePreviewWheel, - handleSavePreview, - }), - [ - previewShape, - previewClosing, - previewZoom, - previewSize, - previewOffset, - openPreview, - closePreview, - adjustPreviewZoom, - togglePreviewZoom, - handlePreviewPointerDown, - handlePreviewPointerMove, - handlePreviewPointerUp, - handlePreviewWheel, - handleSavePreview, - ], - ); -}; diff --git a/frontend/src/features/create-task/hooks/useReplacements.ts b/frontend/src/features/create-task/hooks/useReplacements.ts deleted file mode 100644 index 6974821c..00000000 --- a/frontend/src/features/create-task/hooks/useReplacements.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { useCallback, useMemo, useState } from 'react'; -import type { ImageReplacement, Shape, TextReplacement } from '../types'; -import { resolveAvailablePlaceholders, resolveAvailableShapes } from '../utils'; - -/** - * Hook for managing text and image replacement configurations. - * - * @remarks - * Provides state management for configuring which placeholders and shapes - * in the PowerPoint template should be replaced with data from the spreadsheet. - * - * @returns Replacement state and CRUD handlers - * - * @example - * ```tsx - * const { - * textReplacements, - * addTextReplacement, - * updateTextReplacement, - * removeTextReplacement - * } = useReplacements(); - * ``` - */ -export const useReplacements = () => { - const [textReplacements, setTextReplacements] = useState([]); - const [imageReplacements, setImageReplacements] = useState([]); - const [showTextConfigs, setShowTextConfigs] = useState(false); - const [showImageConfigs, setShowImageConfigs] = useState(false); - - const addTextReplacement = useCallback((maxTextConfigs: number) => { - setTextReplacements((prev) => { - if (prev.length >= maxTextConfigs) return prev; - return [ - ...prev, - { - id: prev.length + 1, - placeholder: '', - columns: [], - }, - ]; - }); - }, []); - - const removeTextReplacement = useCallback((id: number) => { - setTextReplacements((prev) => prev.filter((item) => item.id !== id)); - }, []); - - const updateTextReplacement = useCallback( - (id: number, field: 'placeholder' | 'columns', value: string | string[]) => { - setTextReplacements((prev) => - prev.map((item) => (item.id === id ? { ...item, [field]: value } : item)), - ); - }, - [], - ); - - const addImageReplacement = useCallback((maxImageConfigs: number) => { - setImageReplacements((prev) => { - if (prev.length >= maxImageConfigs) return prev; - return [ - ...prev, - { - id: prev.length + 1, - shapeId: '', - columns: [], - roiType: 'RuleOfThirds', - cropType: 'Fit', - }, - ]; - }); - }, []); - - const removeImageReplacement = useCallback((id: number) => { - setImageReplacements((prev) => prev.filter((item) => item.id !== id)); - }, []); - - const updateImageReplacement = useCallback( - ( - id: number, - field: 'shapeId' | 'columns' | 'roiType' | 'cropType', - value: string | string[], - ) => { - setImageReplacements((prev) => - prev.map((item) => (item.id === id ? { ...item, [field]: value } : item)), - ); - }, - [], - ); - - const clearReplacements = useCallback(() => { - setTextReplacements([]); - setImageReplacements([]); - }, []); - - const getAvailablePlaceholders = useCallback( - (placeholders: string[], current: string) => - resolveAvailablePlaceholders(textReplacements, placeholders, current), - [textReplacements], - ); - - const getAvailableShapes = useCallback( - (shapes: Shape[], current: string) => - resolveAvailableShapes(imageReplacements, shapes, current), - [imageReplacements], - ); - - return useMemo( - () => ({ - textReplacements, - imageReplacements, - showTextConfigs, - showImageConfigs, - setTextReplacements, - setImageReplacements, - setShowTextConfigs, - setShowImageConfigs, - addTextReplacement, - removeTextReplacement, - updateTextReplacement, - addImageReplacement, - removeImageReplacement, - updateImageReplacement, - clearReplacements, - getAvailablePlaceholders, - getAvailableShapes, - }), - [ - textReplacements, - imageReplacements, - showTextConfigs, - showImageConfigs, - addTextReplacement, - removeTextReplacement, - updateTextReplacement, - addImageReplacement, - removeImageReplacement, - updateImageReplacement, - clearReplacements, - getAvailablePlaceholders, - getAvailableShapes, - ], - ); -}; diff --git a/frontend/src/features/create-task/index.ts b/frontend/src/features/create-task/index.ts deleted file mode 100644 index 7d4c11b2..00000000 --- a/frontend/src/features/create-task/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from './CreateTaskMenu'; diff --git a/frontend/src/features/create-task/types/index.ts b/frontend/src/features/create-task/types/index.ts deleted file mode 100644 index 6aec58f9..00000000 --- a/frontend/src/features/create-task/types/index.ts +++ /dev/null @@ -1,209 +0,0 @@ -import type * as backendApi from '@/shared/services/backendApi'; - -export interface CreateTaskMenuProps { - onStart: () => void; -} - -export interface TextReplacement { - id: number; - placeholder: string; - columns: string[]; -} - -export interface ImageReplacement { - id: number; - shapeId: string; - columns: string[]; - roiType: string; - cropType: string; -} - -export interface Shape { - id: string; - name: string; - preview: string; -} - -export interface SavedInputState { - pptxPath?: string; - dataPath?: string; - savePath?: string; - columns?: string[]; - shapes?: Shape[]; - placeholders?: string[]; - sheetNames?: string[]; - selectedSheets?: string[]; - sheetRowCounts?: Record; - sheetCount?: number; - totalRows?: number; - templateLoaded?: boolean; - dataLoaded?: boolean; - textReplacements?: Array<{ - id?: number; - searchText?: string; - placeholder?: string; - columns?: string[]; - }>; - imageReplacements?: Array<{ - id?: number; - shapeId?: string; - columns?: string[]; - roiType?: string; - cropType?: string; - }>; -} - -export type NotificationState = { - type: 'success' | 'error'; - text: string; -}; - -export type TranslationFn = (key: string) => string; - -export type ValidationState = { - canConfigure: boolean; - canStart: boolean; - textShapeCount: number; - imageShapeCount: number; - uniqueColumnCount: number; -}; - -export type RoiOption = { - value: string; - label: string; - description: string; -}; - -export type CropOption = { - value: string; - label: string; - description: string; -}; - -export interface InputNotificationProps { - notification: NotificationState | null; - isClosing: boolean; - onClose: () => void; - t: TranslationFn; -} - -export interface TextReplacementPanelProps { - canConfigure: boolean; - showTextConfigs: boolean; - setShowTextConfigs: React.Dispatch>; - addTextReplacement: () => void; - textReplacements: TextReplacement[]; - maxTextConfigs: number; - getAvailablePlaceholders: (current: string) => string[]; - updateTextReplacement: ( - id: number, - key: 'placeholder' | 'columns', - value: string | string[], - ) => void; - removeTextReplacement: (id: number) => void; - isLoadingPlaceholders: boolean; - placeholders: string[]; - columns: string[]; - t: TranslationFn; -} - -export interface ImageReplacementPanelProps { - canConfigure: boolean; - showImageConfigs: boolean; - setShowImageConfigs: React.Dispatch>; - addImageReplacement: () => void; - imageReplacements: ImageReplacement[]; - maxImageConfigs: number; - shapes: Shape[]; - getAvailableShapes: (current: string) => Shape[]; - updateImageReplacement: ( - id: number, - key: 'shapeId' | 'columns' | 'roiType' | 'cropType', - value: string | string[], - ) => void; - removeImageReplacement: (id: number) => void; - roiOptions: RoiOption[]; - cropOptions: CropOption[]; - getOptionDescription: ( - options: { value: string; description: string }[], - value: string, - ) => string; - columns: string[]; - openPreview: (shape: Shape) => void; - t: TranslationFn; -} - -export interface PreviewModalProps { - previewShape: Shape; - previewClosing: boolean; - closePreview: () => void; - previewSize: { width: number; height: number } | null; - previewZoom: number; - previewOffset: { x: number; y: number }; - adjustPreviewZoom: (delta: number) => void; - setPreviewZoom: (value: number) => void; - handleSavePreview: () => void; - togglePreviewZoom: () => void; - handlePreviewPointerDown: (event: React.PointerEvent) => void; - handlePreviewPointerMove: (event: React.PointerEvent) => void; - handlePreviewPointerUp: (event: React.PointerEvent) => void; - handlePreviewWheel: (event: React.WheelEvent) => void; - setPreviewSize: (size: { width: number; height: number }) => void; - dragMovedRef: React.MutableRefObject; - t: TranslationFn; -} - -export interface MenuHeaderProps { - onImport: () => void; - onExport: () => void; - onClear: () => void; - t: TranslationFn; -} - -export interface TemplateInputSectionProps { - pptxPath: string; - onChangePath: (value: string) => void; - onBrowse: () => void; - isLoadingShapes: boolean; - isLoadingPlaceholders: boolean; - templateLoaded: boolean; - textShapeCount: number; - imageShapeCount: number; - t: TranslationFn; -} - -export interface DataInputSectionProps { - dataPath: string; - onChangePath: (value: string) => void; - onBrowse: () => void; - isLoadingColumns: boolean; - dataLoaded: boolean; - sheetCount: number; - uniqueColumnCount: number; - totalRows: number; - sheetNames: string[]; - selectedSheets: string[]; - sheetRowCounts: Record; - allSheetsSelected: boolean; - someSheetsSelected: boolean; - onToggleAllSheets: () => void; - onToggleSheet: (sheetName: string) => void; - t: TranslationFn; -} - -export interface SaveLocationSectionProps { - savePath: string; - onChangePath: (value: string) => void; - onBrowse: () => void; - t: TranslationFn; -} - -export interface StartButtonSectionProps { - isStarting: boolean; - canStart: boolean; - onStart: () => void; - t: TranslationFn; -} - -export type SlideTextConfig = backendApi.SlideTextConfig; -export type SlideImageConfig = backendApi.SlideImageConfig; diff --git a/frontend/src/features/create-task/utils/index.ts b/frontend/src/features/create-task/utils/index.ts deleted file mode 100644 index 27a1ca98..00000000 --- a/frontend/src/features/create-task/utils/index.ts +++ /dev/null @@ -1,367 +0,0 @@ -import * as backendApi from '@/shared/services/backendApi'; -import { getAssetPath } from '@/shared/utils/paths'; -import type { - ImageReplacement, - SavedInputState, - Shape, - SlideImageConfig, - SlideTextConfig, - TextReplacement, - ValidationState, -} from '../types'; - -export const STORAGE_KEYS = { - inputMenuState: 'slidegen.ui.inputsideBar.state', -}; - -export const loadSavedState = (): SavedInputState | null => { - try { - const saved = sessionStorage.getItem(STORAGE_KEYS.inputMenuState); - if (saved) { - return JSON.parse(saved) as SavedInputState; - } - } catch (error) { - console.error('Error loading saved state:', error); - } - return null; -}; - -export const normalizeSheetNames = (names?: string[]): string[] => { - const unique: string[] = []; - const seen = new Set(); - (names ?? []).forEach((name) => { - if (typeof name !== 'string') return; - if (!name || seen.has(name)) return; - seen.add(name); - unique.push(name); - }); - return unique; -}; - -export const normalizeSheetRowCounts = ( - counts?: Record, -): Record => { - const normalized: Record = {}; - Object.entries(counts ?? {}).forEach(([key, value]) => { - if (!key) return; - normalized[key] = Number.isFinite(value) ? value : 0; - }); - return normalized; -}; - -export const buildSheetInfo = ( - sheetsInfo: Array<{ Name?: string | null; RowCount?: number | null }>, -): { sheetNames: string[]; sheetRowCounts: Record } => { - const sheetNames: string[] = []; - const sheetRowCounts: Record = {}; - const seen = new Set(); - - for (const sheet of sheetsInfo) { - const originalName = sheet.Name ?? ''; - if (!originalName) continue; - if (!seen.has(originalName)) { - sheetNames.push(originalName); - seen.add(originalName); - } - sheetRowCounts[originalName] = sheet.RowCount ?? 0; - } - - return { sheetNames, sheetRowCounts }; -}; - -export const resolveRequestedSheets = ( - availableSheets: string[], - requestedSheets?: string[] | null, - fallbackToAll = true, -): string[] => { - if (availableSheets.length === 0) return []; - if (!requestedSheets) return availableSheets; - if (requestedSheets.length === 0) return fallbackToAll ? availableSheets : []; - const requestedSet = new Set(requestedSheets); - const resolved = availableSheets.filter((name) => requestedSet.has(name)); - if (resolved.length > 0) return resolved; - return fallbackToAll ? availableSheets : []; -}; - -export const mapTemplateShapes = (template: backendApi.SlideScanTemplateSuccess): Shape[] => { - return (template.shapes ?? []) - .filter((shape) => shape.isImage === true) - .map((shape) => ({ - id: String(shape.id), - name: shape.name, - preview: shape.data - ? `data:image/png;base64,${shape.data}` - : getAssetPath('images', 'app-icon.png'), - })); -}; - -export const mapTemplatePlaceholders = ( - template: backendApi.SlideScanTemplateSuccess, -): string[] => { - const items = (template.placeholders ?? []) - .map((item) => item.trim()) - .filter((item) => item.length > 0); - return Array.from(new Set(items)).sort((a, b) => a.localeCompare(b)); -}; - -export const loadTemplateAssets = async ( - filePath: string, -): Promise<{ shapes: Shape[]; placeholders: string[] }> => { - const response = await backendApi.scanTemplate(filePath); - const template = response as backendApi.SlideScanTemplateSuccess; - return { - shapes: mapTemplateShapes(template), - placeholders: mapTemplatePlaceholders(template), - }; -}; - -export const loadDataAssets = async ( - filePath: string, -): Promise<{ - columns: string[]; - sheetCount: number; - totalRows: number; - sheetNames: string[]; - sheetRowCounts: Record; -}> => { - await backendApi.loadFile(filePath); - const columns = await backendApi.getAllColumns([filePath]); - const workbookInfo = await backendApi.getWorkbookInfo(filePath); - const workbookData = workbookInfo as backendApi.SheetWorkbookGetInfoSuccess; - const sheetsInfo = workbookData.Sheets ?? []; - const rowsSum = sheetsInfo.reduce((acc, sheet) => acc + (sheet.RowCount ?? 0), 0); - const sheetInfo = buildSheetInfo(sheetsInfo); - return { - columns, - sheetCount: sheetsInfo.length, - totalRows: rowsSum, - sheetNames: sheetInfo.sheetNames, - sheetRowCounts: sheetInfo.sheetRowCounts, - }; -}; - -export const mapTextReplacements = ( - savedState: SavedInputState, - placeholders: string[], - columns: string[], -): TextReplacement[] => { - const placeholderSet = new Set(placeholders); - const columnSet = new Set(columns); - const importedText = (savedState.textReplacements || []).map((item) => ({ - id: item.id ?? 1, - placeholder: item.placeholder || item.searchText || '', - columns: item.columns || [], - })); - return importedText - .map((item) => ({ - ...item, - placeholder: item.placeholder.trim(), - columns: item.columns.filter((col) => columnSet.has(col)), - })) - .filter( - (item) => item.placeholder && item.columns.length > 0 && placeholderSet.has(item.placeholder), - ); -}; - -export const mapImageReplacements = ( - savedState: SavedInputState, - shapes: Shape[], - columns: string[], -): ImageReplacement[] => { - const shapeIdSet = new Set(shapes.map((shape) => shape.id)); - const columnSet = new Set(columns); - const importedImages = (savedState.imageReplacements || []).map((item) => ({ - id: item.id ?? 1, - shapeId: item.shapeId ?? '', - columns: item.columns ?? [], - roiType: item.roiType ?? 'RuleOfThirds', - cropType: item.cropType ?? 'Fit', - })); - return importedImages - .map((item) => ({ - ...item, - shapeId: item.shapeId.trim(), - columns: item.columns.filter((col) => columnSet.has(col)), - })) - .filter((item) => item.shapeId && item.columns.length > 0 && shapeIdSet.has(item.shapeId)); -}; - -export const resolvePath = (inputPath: string): string => { - if (!inputPath) return inputPath; - - if (/^[a-zA-Z]:[/\\]/.test(inputPath) || inputPath.startsWith('/')) { - return inputPath; - } - - if (typeof process === 'undefined' || !process.cwd) { - return inputPath; - } - - const cwd = process.cwd(); - return `${cwd}\\${inputPath.replace(/\//g, '\\')}`; -}; - -export const buildTextConfigs = (textReplacements: TextReplacement[]): SlideTextConfig[] => { - return textReplacements - .filter((item) => item.placeholder.trim() && item.columns.length > 0) - .map((item) => ({ - pattern: item.placeholder.trim(), - columns: item.columns, - })); -}; - -export const buildImageConfigs = (imageReplacements: ImageReplacement[]): SlideImageConfig[] => { - return imageReplacements - .filter((item) => item.shapeId && item.columns.length > 0) - .map((item) => ({ - shapeId: Number(item.shapeId), - columns: item.columns, - roiType: item.roiType || 'Center', - cropType: item.cropType || 'Crop', - })) - .filter((item) => Number.isFinite(item.shapeId)); -}; - -export const resolveAvailablePlaceholders = ( - textReplacements: TextReplacement[], - placeholders: string[], - current: string, -): string[] => { - const taken = new Set( - textReplacements - .map((item) => item.placeholder.trim()) - .filter((value) => value && value !== current), - ); - return placeholders.filter((value) => !taken.has(value)); -}; - -export const resolveAvailableShapes = ( - imageReplacements: ImageReplacement[], - shapes: Shape[], - current: string, -): Shape[] => { - const taken = new Set( - imageReplacements - .map((item) => item.shapeId.trim()) - .filter((value) => value && value !== current), - ); - return shapes.filter((shape) => !taken.has(shape.id)); -}; - -export const computeValidationState = (args: { - pptxPath: string; - dataPath: string; - savePath: string; - isLoadingColumns: boolean; - isLoadingShapes: boolean; - isLoadingPlaceholders: boolean; - placeholders: string[]; - shapes: Shape[]; - columns: string[]; - textReplacements: TextReplacement[]; - imageReplacements: ImageReplacement[]; - sheetNames: string[]; - selectedSheets: string[]; - sheetRowCounts: Record; -}): ValidationState => { - const templateExtPattern = /\.(pptx|potx)$/i; - const sheetExtPattern = /\.(xlsx|xlsm)$/i; - const isTemplateValid = Boolean(args.pptxPath && templateExtPattern.test(args.pptxPath)); - const isDataValid = Boolean(args.dataPath && sheetExtPattern.test(args.dataPath)); - const isOutputValid = Boolean(args.savePath && args.savePath.trim().length > 0); - - const canConfigure = - isTemplateValid && - isDataValid && - !args.isLoadingColumns && - !args.isLoadingShapes && - !args.isLoadingPlaceholders; - - const placeholderSet = new Set(args.placeholders); - const shapeIdSet = new Set(args.shapes.map((shape) => shape.id)); - - const normalizedTextPlaceholders = args.textReplacements.map((item) => item.placeholder.trim()); - const usedTextPlaceholders = new Set( - normalizedTextPlaceholders.filter((value) => value.length > 0), - ); - const hasDuplicateTextPlaceholders = - usedTextPlaceholders.size !== - normalizedTextPlaceholders.filter((value) => value.length > 0).length; - - const normalizedShapeIds = args.imageReplacements.map((item) => item.shapeId.trim()); - const usedShapeIds = new Set(normalizedShapeIds.filter((value) => value.length > 0)); - const hasDuplicateShapeIds = - usedShapeIds.size !== normalizedShapeIds.filter((value) => value.length > 0).length; - - const invalidTextItems = args.textReplacements.filter((item) => { - const placeholder = item.placeholder.trim(); - if (!placeholder || item.columns.length === 0) return true; - return !placeholderSet.has(placeholder); - }); - - const invalidImageItems = args.imageReplacements.filter((item) => { - const shapeId = item.shapeId.trim(); - if (!shapeId || item.columns.length === 0) return true; - return !shapeIdSet.has(shapeId); - }); - - const validTextCount = args.textReplacements.length - invalidTextItems.length; - const validImageCount = args.imageReplacements.length - invalidImageItems.length; - const hasAnyConfig = validTextCount + validImageCount > 0; - - const hasInvalidConfig = - invalidTextItems.length > 0 || - invalidImageItems.length > 0 || - hasDuplicateTextPlaceholders || - hasDuplicateShapeIds; - - const hasSelectedSheets = args.sheetNames.length === 0 || args.selectedSheets.length > 0; - const hasSelectedRows = - args.sheetNames.length > 0 && - args.selectedSheets.reduce((sum, sheet) => sum + (args.sheetRowCounts[sheet] ?? 0), 0) > 0; - - const canStart = - isTemplateValid && - isDataValid && - isOutputValid && - hasAnyConfig && - !hasInvalidConfig && - hasSelectedSheets && - hasSelectedRows; - - return { - canConfigure, - canStart, - textShapeCount: args.placeholders.length, - imageShapeCount: args.shapes.length, - uniqueColumnCount: args.columns.length, - }; -}; - -export const splitNotificationText = (text: string): { title: string; detail: string } => { - const idx = text.indexOf(':'); - if (idx <= 0 || idx === text.length - 1) { - return { title: text.trim(), detail: '' }; - } - return { - title: text.slice(0, idx).trim(), - detail: text.slice(idx + 1).trim(), - }; -}; - -export const getErrorDetail = (error: unknown): string => { - if (error instanceof Error && error.message) return error.message; - if (typeof error === 'string') return error; - if (error && typeof error === 'object' && 'message' in error) { - const value = (error as { message?: string }).message; - if (value) return value; - } - return ''; -}; - -export const getOptionDescription = ( - options: { value: string; description: string }[], - value: string, -): string => { - return options.find((option) => option.value === value)?.description ?? ''; -}; diff --git a/frontend/src/features/process/ProcessMenu.css b/frontend/src/features/process/ProcessMenu.css deleted file mode 100644 index 48e8ce7a..00000000 --- a/frontend/src/features/process/ProcessMenu.css +++ /dev/null @@ -1,527 +0,0 @@ -.process-menu { - max-width: 1040px; - margin: 0 auto; - display: flex; - flex-direction: column; - gap: var(--spacing-2xl); - animation: fadeInUp var(--transition-base); -} - -.process-menu .header-actions { - display: flex; - gap: var(--spacing-sm); - flex-wrap: wrap; -} - -.process-menu .header-actions .btn { - min-width: 150px; - justify-content: center; -} - -.process-menu .process-section { - padding: var(--spacing-2xl); - background-color: var(--bg-tertiary); - border-radius: var(--radius-lg); - border: 1px solid var(--border-primary); - box-shadow: var(--shadow-sm); -} - -.process-menu .process-list { - display: flex; - flex-direction: column; - gap: var(--spacing-lg); -} - -.process-menu .process-group { - background-color: var(--bg-secondary); - border: 1px solid var(--border-primary); - border-radius: var(--radius-lg); - overflow: hidden; - transition: - border-color var(--transition-fast), - box-shadow var(--transition-fast); -} - -.process-menu .process-group:hover { - border-color: var(--accent-primary); - box-shadow: var(--shadow-sm); -} - -.process-menu .group-header { - display: flex; - justify-content: space-between; - align-items: center; - padding: var(--spacing-lg); - cursor: pointer; - transition: background-color var(--transition-fast); -} - -.process-menu .group-header:hover { - background-color: var(--bg-hover); -} - -.process-menu .group-main-info { - display: flex; - align-items: center; - gap: var(--spacing-md); - flex: 1; -} - -.process-menu .expand-icon { - width: 12px; - height: 12px; -} - -.process-menu .file-expand-icon, -.process-menu .log-row-toggle { - width: 10px; - height: 10px; -} - -.process-menu .expand-icon, -.process-menu .file-expand-icon, -.process-menu .log-row-toggle { - object-fit: contain; - display: inline-block; - filter: brightness(0) invert(1); - transition: transform var(--transition-fast); - transform: rotate(-90deg); -} - -.process-menu .expand-icon.expanded, -.process-menu .file-expand-icon.expanded, -.process-menu .log-row-toggle.expanded { - transform: rotate(0deg); -} - -.process-menu .group-info { - flex: 1; -} - -.process-menu .group-name { - font-size: var(--font-lg); - font-weight: var(--font-semibold); - color: var(--text-primary); - margin-bottom: var(--spacing-xs); -} - -.process-menu .group-name-row { - display: flex; - align-items: baseline; - gap: var(--spacing-sm); - flex-wrap: wrap; -} - -.process-menu .group-time { - font-size: var(--font-xs); - color: var(--text-tertiary); - font-family: var(--font-mono); -} - -.process-menu .group-stats-line { - display: flex; - align-items: center; - gap: var(--spacing-sm); - color: var(--text-tertiary); - font-size: var(--font-sm); - flex-wrap: wrap; -} - -.process-menu .stat-badge { - padding: 4px 10px; - border-radius: var(--radius-full); - font-size: 11px; - font-weight: var(--font-semibold); -} - -.process-menu .stat-success { - background-color: rgba(16, 185, 129, 0.14); - color: #10b981; -} - -.process-menu .stat-processing { - background-color: rgba(59, 130, 246, 0.14); - color: #3b82f6; -} - -.process-menu .stat-failed { - background-color: rgba(239, 68, 68, 0.14); - color: #ef4444; -} - -.process-menu .stat-divider { - color: var(--text-tertiary); -} - -.process-menu .group-actions { - display: flex; - gap: var(--spacing-sm); -} - -.process-menu .process-btn { - padding: 8px 12px; - border-radius: var(--radius-md); - border: 1px solid var(--border-primary); - background-color: var(--bg-tertiary); - color: var(--text-primary); - cursor: pointer; - transition: - border-color var(--transition-fast), - transform var(--transition-fast), - background-color var(--transition-fast); - display: flex; - align-items: center; - gap: var(--spacing-sm); -} - -.process-menu .process-btn-icon-only { - padding: 8px; - border: none; - background: transparent; -} - -.process-menu .process-btn-icon-only:hover:not(:disabled) { - border-color: transparent; - background: transparent; - box-shadow: none; -} - -.process-menu .process-btn:hover:not(:disabled) { - border-color: var(--accent-primary); - transform: translateY(-1px); -} - -.process-menu .process-btn-icon { - min-width: 40px; - justify-content: center; -} - -.process-menu .process-btn-danger { - border-color: rgba(239, 68, 68, 0.3); - color: #ef4444; - background-color: rgba(239, 68, 68, 0.08); -} - -.process-menu .progress-bar-container { - height: 8px; - background-color: var(--bg-tertiary); -} - -.process-menu .progress-bar-fill { - height: 100%; - transition: width var(--transition-slow); -} - -.process-menu .files-list { - padding: var(--spacing-lg); - background-color: var(--bg-tertiary); - border-top: 1px solid var(--border-primary); - display: flex; - flex-direction: column; - gap: var(--spacing-md); - animation: fadeInUp var(--transition-base); -} - -.process-menu .file-item { - background-color: var(--bg-secondary); - border: 1px solid var(--border-primary); - border-radius: var(--radius-md); - padding: var(--spacing-lg); -} - -.process-menu .file-header-clickable { - display: flex; - justify-content: space-between; - align-items: center; - gap: var(--spacing-md); - cursor: pointer; -} - -.process-menu .file-info { - flex: 1; -} - -.process-menu .file-name { - font-size: var(--font-base); - font-weight: var(--font-semibold); - font-family: var(--font-mono); - color: var(--text-primary); - margin-bottom: var(--spacing-xs); -} - -.process-menu .file-name-row { - display: flex; - align-items: baseline; - gap: var(--spacing-sm); - flex-wrap: wrap; - margin-bottom: var(--spacing-xs); -} - -.process-menu .file-name-row .file-name { - margin-bottom: 0; -} - -.process-menu .file-job-id { - font-size: 11px; - color: var(--text-tertiary); - font-family: var(--font-mono); -} - -.process-menu .file-stats { - display: flex; - align-items: center; - gap: var(--spacing-xs); - color: var(--text-tertiary); - font-size: var(--font-sm); - flex-wrap: wrap; -} - -.process-menu .file-stat-badge { - padding: 2px 8px; - border-radius: var(--radius-full); - font-size: 10px; - font-weight: var(--font-semibold); -} - -.process-menu .file-stat-badge.stat-success { - background-color: rgba(16, 185, 129, 0.14); - color: #10b981; -} - -.process-menu .file-stat-badge.stat-processing { - background-color: rgba(59, 130, 246, 0.14); - color: #3b82f6; -} - -.process-menu .file-stat-badge.stat-failed { - background-color: rgba(239, 68, 68, 0.14); - color: #ef4444; -} - -.process-menu .file-progress-text { - color: var(--text-tertiary); -} - -.process-menu .file-status-and-actions { - display: flex; - align-items: center; - gap: var(--spacing-sm); -} - -.process-menu .file-status { - padding: 4px 10px; - border-radius: var(--radius-full); - font-size: 10px; - font-weight: var(--font-semibold); - text-transform: uppercase; -} - -.process-menu .file-status[data-status='processing'] { - background-color: rgba(59, 130, 246, 0.14); - color: #3b82f6; -} - -.process-menu .file-status[data-status='pending'] { - background-color: rgba(148, 163, 184, 0.18); - color: #94a3b8; -} - -.process-menu .file-status[data-status='paused'] { - background-color: rgba(245, 158, 11, 0.14); - color: #f59e0b; -} - -.process-menu .file-status[data-status='completed'] { - background-color: rgba(16, 185, 129, 0.14); - color: #10b981; -} - -.process-menu .file-status[data-status='failed'], -.process-menu .file-status[data-status='error'], -.process-menu .file-status[data-status='cancelled'] { - background-color: rgba(239, 68, 68, 0.14); - color: #ef4444; -} - -.process-menu .file-action-btn { - width: 32px; - height: 32px; - border-radius: var(--radius-md); - border: 1px solid var(--border-primary); - background-color: var(--bg-tertiary); - display: grid; - place-items: center; - cursor: pointer; - transition: - border-color var(--transition-fast), - transform var(--transition-fast); -} - -.process-menu .file-action-btn:hover { - border-color: var(--accent-primary); - transform: translateY(-1px); -} - -.process-menu .file-action-btn-danger { - border-color: rgba(239, 68, 68, 0.35); - background-color: rgba(239, 68, 68, 0.12); -} - -.process-menu .file-progress-bar { - height: 6px; - background-color: var(--bg-tertiary); - border-radius: var(--radius-full); - overflow: hidden; - margin: var(--spacing-sm) 0; -} - -.process-menu .file-progress-fill { - height: 100%; - border-radius: inherit; - transition: width var(--transition-slow); -} - -.process-menu .file-log-content { - border: 1px solid var(--border-primary); - border-radius: var(--radius-md); - overflow: hidden; - margin-top: var(--spacing-lg); -} - -.process-menu .log-header { - display: flex; - justify-content: space-between; - align-items: center; - padding: var(--spacing-sm) var(--spacing-md); - background-color: var(--bg-tertiary); - border-bottom: 1px solid var(--border-primary); - font-size: var(--font-sm); - color: var(--text-secondary); - font-weight: var(--font-semibold); -} - -.process-menu .copy-log-btn { - background: transparent; - border: none; - padding: 4px; - cursor: pointer; -} - -.process-menu .log-icon { - width: 16px; - height: 16px; - object-fit: contain; - filter: brightness(0) invert(1); -} - -[data-theme='light'] .process-menu .log-icon { - filter: none; -} - -.process-menu .log-content { - max-height: 200px; - overflow-y: auto; - padding: var(--spacing-sm) var(--spacing-md); - background-color: var(--bg-secondary); -} - -.process-menu .log-row-group { - border: 1px solid var(--border-secondary); - border-radius: var(--radius-sm); - margin-bottom: var(--spacing-sm); - overflow: hidden; - background-color: var(--bg-tertiary); -} - -.process-menu .log-row-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 6px 10px; - font-size: 11px; - font-weight: var(--font-semibold); - text-transform: uppercase; - letter-spacing: 0.04em; - background-color: rgba(148, 163, 184, 0.15); - color: var(--text-secondary); - cursor: pointer; - gap: var(--spacing-sm); -} - -[data-theme='light'] .process-menu .expand-icon, -[data-theme='light'] .process-menu .file-expand-icon, -[data-theme='light'] .process-menu .log-row-toggle { - filter: none; -} - -.process-menu .log-row-title { - flex: 1; -} - -.process-menu .log-row-group[data-status='processing'] .log-row-header { - background-color: rgba(59, 130, 246, 0.16); - color: #3b82f6; -} - -.process-menu .log-row-group[data-status='completed'] .log-row-header { - background-color: rgba(16, 185, 129, 0.16); - color: #10b981; -} - -.process-menu .log-row-group[data-status='warning'] .log-row-header { - background-color: rgba(245, 158, 11, 0.2); - color: #f59e0b; -} - -.process-menu .log-row-group[data-status='error'] .log-row-header { - background-color: rgba(239, 68, 68, 0.2); - color: #ef4444; -} - -.process-menu .log-row-entries { - padding: 4px 10px; -} - -.process-menu .log-entry { - font-size: var(--font-sm); - color: var(--text-secondary); - font-family: var(--font-mono); - padding: 6px 8px; - border-radius: var(--radius-sm); - margin-bottom: 6px; - background-color: rgba(148, 163, 184, 0.1); -} - -.process-menu .log-entry:last-child { - margin-bottom: 0; -} - -.process-menu .log-entry.log-info { - background-color: rgba(59, 130, 246, 0.12); -} - -.process-menu .log-entry.log-warning { - background-color: rgba(245, 158, 11, 0.18); -} - -.process-menu .log-entry.log-error { - background-color: rgba(239, 68, 68, 0.16); -} - -.process-menu .log-empty { - text-align: center; - font-size: var(--font-sm); - color: var(--text-tertiary); - padding: var(--spacing-sm) 0; -} - -@media (max-width: 900px) { - .process-menu .menu-header { - flex-direction: column; - align-items: flex-start; - } - - .process-menu .header-actions { - width: 100%; - } -} diff --git a/frontend/src/features/process/ProcessMenu.tsx b/frontend/src/features/process/ProcessMenu.tsx deleted file mode 100644 index d47bb4a1..00000000 --- a/frontend/src/features/process/ProcessMenu.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import React from 'react'; -import type { SheetJob } from '@/shared/contexts/JobContextType'; -import type { LogEntry } from './types'; -import { useProcess } from './hooks'; -import { ProcessGroup, ProcessHeader } from './components'; -import './ProcessMenu.css'; - -const ProcessMenu: React.FC = () => { - const process = useProcess(); - - const handleCopyLogs = (sheet: SheetJob) => { - const logJobLabel = sheet.hangfireJobId ? `#${sheet.hangfireJobId}` : sheet.id; - navigator.clipboard.writeText( - sheet.logs.map((entry) => process.formatLogEntry(entry as LogEntry, logJobLabel)).join('\n'), - ); - }; - - return ( -
- - -
- {process.activeGroups.length === 0 ? ( -
{process.t('process.empty')}
- ) : ( -
- {process.activeGroups.map((group) => ( - process.toggleGroup(group.id)} - onToggleLog={process.toggleLog} - onToggleRowGroup={process.toggleRowGroup} - onGroupAction={() => process.handleGroupAction(group.id, group.status)} - onStopGroup={() => process.handleStopGroup(group.id)} - onExportGroup={() => process.handleExportGroup(group.id)} - onSheetAction={process.handleSheetAction} - onStopSheet={process.handleStopSheet} - onCopyLogs={handleCopyLogs} - t={process.t} - /> - ))} -
- )} -
-
- ); -}; - -export default ProcessMenu; diff --git a/frontend/src/features/process/__tests__/ProcessMenu.test.tsx b/frontend/src/features/process/__tests__/ProcessMenu.test.tsx deleted file mode 100644 index 1a05c917..00000000 --- a/frontend/src/features/process/__tests__/ProcessMenu.test.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import ProcessMenu from '../ProcessMenu'; - -const groupControl = vi.fn(); -const jobControl = vi.fn(); -const globalControl = vi.fn(); -const exportGroupConfig = vi.fn(); - -vi.mock('@/shared/contexts/useApp', () => ({ - useApp: () => ({ t: (key: string) => key }), -})); - -vi.mock('@/shared/contexts/useJobs', () => ({ - useJobs: () => ({ - groups: [ - { - id: 'group-1', - workbookPath: 'C:\\book.xlsx', - status: 'Running', - progress: 35, - errorCount: 0, - sheets: { - 'sheet-1': { - id: 'sheet-1', - sheetName: 'Sheet1', - status: 'Running', - currentRow: 1, - totalRows: 3, - progress: 33, - errorCount: 0, - hangfireJobId: '32', - logs: [ - { - message: 'Processing row 1', - level: 'Info', - row: 1, - rowStatus: 'processing', - timestamp: new Date('2025-01-01T10:00:00Z').toISOString(), - }, - { - message: 'Row 1 completed (text: 1, images: 1, image errors: 0)', - level: 'Info', - row: 1, - rowStatus: 'completed', - timestamp: new Date('2025-01-01T10:00:02Z').toISOString(), - }, - ], - }, - }, - logs: [], - }, - ], - groupControl, - jobControl, - globalControl, - loadSheetLogs: vi.fn(), - exportGroupConfig, - hasGroupConfig: () => true, - }), -})); - -vi.mock('@/shared/services/signalrClient', () => ({ - getBackendBaseUrl: () => 'http://localhost:5000', -})); - -describe('ProcessMenu', () => { - it('groups logs by row and shows per-sheet stop action', async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByText('book.xlsx')); - await user.click(screen.getByText('Sheet1')); - - expect(screen.getByText('process.jobId: #32')).toBeInTheDocument(); - expect(screen.getByText('Row 1')).toBeInTheDocument(); - expect(document.querySelector('.file-action-btn-danger')).not.toBeNull(); - }); -}); diff --git a/frontend/src/features/process/components/ProcessGroup.tsx b/frontend/src/features/process/components/ProcessGroup.tsx deleted file mode 100644 index d752f500..00000000 --- a/frontend/src/features/process/components/ProcessGroup.tsx +++ /dev/null @@ -1,192 +0,0 @@ -import React, { memo, useMemo } from 'react'; -import { getAssetPath } from '@/shared/utils/paths'; -import type { SheetJob } from '@/shared/contexts/JobContextType'; -import type { LogEntry, RowLogGroup, TranslationFn } from '../types'; -import { - deriveGroupName, - groupLogsByRow, - progressColor, - statusKey, - summarizeSheets, -} from '../utils'; -import { SheetItem } from './SheetItem'; - -interface ProcessGroupProps { - group: { - id: string; - status: string; - progress: number; - workbookPath: string; - createdAt?: string; - sheets: Record; - }; - showDetails: boolean; - expandedLogs: Record; - collapsedRowGroups: Record; - hasGroupConfig: (groupId: string) => boolean; - formatLogEntry: (entry: LogEntry, jobLabel?: string) => string; - formatTime: (value?: string) => string; - onToggleGroup: () => void; - onToggleLog: (sheetId: string) => void; - onToggleRowGroup: (key: string) => void; - onGroupAction: () => void; - onStopGroup: () => void; - onExportGroup: () => void; - onSheetAction: (sheetId: string, status: string) => void; - onStopSheet: (sheetId: string) => void; - onCopyLogs: (sheet: SheetJob) => void; - t: TranslationFn; -} - -export const ProcessGroup: React.FC = memo( - ({ - group, - showDetails, - expandedLogs, - collapsedRowGroups, - hasGroupConfig, - formatLogEntry, - formatTime, - onToggleGroup, - onToggleLog, - onToggleRowGroup, - onGroupAction, - onStopGroup, - onExportGroup, - onSheetAction, - onStopSheet, - onCopyLogs, - t, - }) => { - const sheets = useMemo(() => Object.values(group.sheets), [group.sheets]); - const summary = useMemo(() => summarizeSheets(sheets), [sheets]); - const { completedJobs, totalSlides, completedSlides, processingSlides, failedSlides } = summary; - const totalSheets = sheets.length; - const groupProgress = group.progress; - const groupName = useMemo( - () => deriveGroupName(group.workbookPath, group.id), - [group.workbookPath, group.id], - ); - - return ( -
-
-
- -
-
-
{groupName}
- {group.createdAt && ( - - {t('process.createdAt')}: {formatTime(group.createdAt)} - - )} -
-
- - {completedSlides}/{totalSlides} {t('process.slides')} ({completedJobs}/ - {totalSheets}) - {Math.round(groupProgress)}% - - - {completedSlides} - - | - - {processingSlides} - - | - - {failedSlides} - -
-
-
-
e.stopPropagation()}> - - - -
-
- -
-
-
- - {showDetails && ( -
- {sheets.map((sheet) => { - const showLog = expandedLogs[sheet.id] ?? false; - const logGroups: RowLogGroup[] = showLog - ? groupLogsByRow(sheet.logs as LogEntry[]) - : []; - - return ( - onToggleLog(sheet.id)} - onToggleRowGroup={onToggleRowGroup} - onSheetAction={() => onSheetAction(sheet.id, sheet.status)} - onStopSheet={() => onStopSheet(sheet.id)} - onCopyLogs={() => onCopyLogs(sheet)} - t={t} - /> - ); - })} -
- )} -
- ); - }, -); - -ProcessGroup.displayName = 'ProcessGroup'; diff --git a/frontend/src/features/process/components/ProcessHeader.tsx b/frontend/src/features/process/components/ProcessHeader.tsx deleted file mode 100644 index fbff9c97..00000000 --- a/frontend/src/features/process/components/ProcessHeader.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import React from 'react'; -import { getAssetPath } from '@/shared/utils/paths'; -import type { ProcessHeaderProps } from '../types'; - -export const ProcessHeader: React.FC = ({ - hasProcessing, - activeGroupsCount, - onPauseResumeAll, - onStopAll, - onOpenDashboard, - t, -}) => ( -
-

{t('process.title')}

-
- - - -
-
-); diff --git a/frontend/src/features/process/components/SheetItem.tsx b/frontend/src/features/process/components/SheetItem.tsx deleted file mode 100644 index 70ff0ade..00000000 --- a/frontend/src/features/process/components/SheetItem.tsx +++ /dev/null @@ -1,184 +0,0 @@ -import React, { memo, useMemo } from 'react'; -import { getAssetPath } from '@/shared/utils/paths'; -import type { SheetItemProps } from '../types'; -import { getSheetStats } from '../utils'; - -export const SheetItem: React.FC = memo( - ({ - sheet, - showLog, - logGroups, - collapsedRowGroups, - statusKey, - progressColor, - formatLogEntry, - onToggleLog, - onToggleRowGroup, - onSheetAction, - onStopSheet, - onCopyLogs, - t, - }) => { - const stats = useMemo(() => getSheetStats(sheet), [sheet]); - const { completedSlides, failedSlides, processingSlides } = stats; - const isPaused = sheet.status === 'Paused'; - const canControl = useMemo( - () => ['Running', 'Paused', 'Pending'].includes(sheet.status), - [sheet.status], - ); - const jobIdLabel = sheet.hangfireJobId ? `#${sheet.hangfireJobId}` : '-'; - const logJobLabel = sheet.hangfireJobId ? `#${sheet.hangfireJobId}` : sheet.id; - - return ( -
-
- -
-
-
{sheet.sheetName}
- - {t('process.jobId')}: {jobIdLabel} - -
-
- - {completedSlides} - - | - - {processingSlides} - - | - - {failedSlides} - - - / {sheet.totalRows} {t('process.slides')} - {Math.round(sheet.progress)}% - -
-
-
-
- {t(`process.status.${statusKey(sheet.status)}`)} -
- {canControl && ( - - )} - {canControl && ( - - )} -
-
- -
-
-
- - {showLog && ( -
-
- {t('process.log')} - -
-
- {sheet.logs.length === 0 ? ( -
{t('process.noLogs')}
- ) : ( - logGroups.map((group) => { - const rowKey = `${sheet.id}:${group.key}`; - const isCollapsed = collapsedRowGroups[rowKey] ?? true; - return ( -
-
onToggleRowGroup(rowKey)}> - - - {group.row != null ? `Row ${group.row}` : t('process.logGeneral')} - - {group.status && ( - {group.status.toUpperCase()} - )} -
- {!isCollapsed && ( -
- {group.entries.map((entry, index) => ( -
- {formatLogEntry(entry, logJobLabel)} -
- ))} -
- )} -
- ); - }) - )} -
-
- )} -
- ); - }, -); - -SheetItem.displayName = 'SheetItem'; diff --git a/frontend/src/features/process/components/index.ts b/frontend/src/features/process/components/index.ts deleted file mode 100644 index 631e822d..00000000 --- a/frontend/src/features/process/components/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { SheetItem } from './SheetItem'; -export { ProcessHeader } from './ProcessHeader'; -export { ProcessGroup } from './ProcessGroup'; diff --git a/frontend/src/features/process/hooks/index.ts b/frontend/src/features/process/hooks/index.ts deleted file mode 100644 index d4b41260..00000000 --- a/frontend/src/features/process/hooks/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { useProcess } from './useProcess'; diff --git a/frontend/src/features/process/hooks/useProcess.ts b/frontend/src/features/process/hooks/useProcess.ts deleted file mode 100644 index 786085fd..00000000 --- a/frontend/src/features/process/hooks/useProcess.ts +++ /dev/null @@ -1,189 +0,0 @@ -import { useCallback, useMemo, useState } from 'react'; -import { useApp } from '@/shared/contexts/useApp'; -import { useJobs } from '@/shared/contexts/useJobs'; -import { getBackendBaseUrl } from '@/shared/services/signalrClient'; -import type { LogEntry } from '../types'; -import { formatLogEntry, formatTime } from '../utils'; - -/** - * Hook for managing slide generation process monitoring and control. - * - * @remarks - * Provides state and handlers for: - * - Viewing active and completed job groups - * - Expanding/collapsing job details and logs - * - Pausing, resuming, and stopping jobs - * - Exporting job configurations - * - * @returns Process management state and action handlers - * - * @example - * ```tsx - * const { - * activeGroups, - * expandedGroups, - * toggleGroup, - * handlePauseResumeAll - * } = useProcess(); - * ``` - */ -export const useProcess = () => { - const { t, language } = useApp(); - const { - groups, - groupControl, - jobControl, - globalControl, - loadSheetLogs, - exportGroupConfig, - hasGroupConfig, - } = useJobs(); - - const [expandedGroups, setExpandedGroups] = useState>({}); - const [expandedLogs, setExpandedLogs] = useState>({}); - const [collapsedRowGroups, setCollapsedRowGroups] = useState>({}); - - const toggleGroup = useCallback((groupId: string) => { - setExpandedGroups((prev) => ({ ...prev, [groupId]: !prev[groupId] })); - }, []); - - const toggleLog = useCallback( - (sheetId: string) => { - setExpandedLogs((prev) => { - const next = !prev[sheetId]; - if (next) { - void loadSheetLogs(sheetId); - } - return { ...prev, [sheetId]: next }; - }); - }, - [loadSheetLogs], - ); - - const toggleRowGroup = useCallback((key: string) => { - setCollapsedRowGroups((prev) => { - const current = prev[key] ?? true; - return { ...prev, [key]: !current }; - }); - }, []); - - const activeGroups = useMemo( - () => - groups.filter( - (group) => !['completed', 'failed', 'cancelled'].includes(group.status.toLowerCase()), - ), - [groups], - ); - - const hasProcessing = useMemo( - () => activeGroups.some((group) => ['running', 'pending'].includes(group.status.toLowerCase())), - [activeGroups], - ); - - const handlePauseResumeAll = useCallback(async () => { - const action = hasProcessing ? 'Pause' : 'Resume'; - await globalControl(action); - }, [hasProcessing, globalControl]); - - const handleStopAll = useCallback(async () => { - if (confirm(t('process.confirmStopAll'))) { - await globalControl('Stop'); - } - }, [t, globalControl]); - - const handleOpenDashboard = useCallback(async () => { - const url = `${getBackendBaseUrl()}/dashboard`; - if (window.electronAPI?.openUrl) { - await window.electronAPI.openUrl(url); - return; - } - window.open(url, '_blank'); - }, []); - - const handleGroupAction = useCallback( - async (groupId: string, status: string) => { - const normalized = status.toLowerCase(); - if (normalized === 'paused') { - await groupControl(groupId, 'Resume'); - return; - } - if (normalized === 'running' || normalized === 'pending') { - await groupControl(groupId, 'Pause'); - } - }, - [groupControl], - ); - - const handleStopGroup = useCallback( - async (groupId: string) => { - if (confirm(t('process.confirmStop'))) { - await groupControl(groupId, 'Stop'); - } - }, - [t, groupControl], - ); - - const handleExportGroup = useCallback( - async (groupId: string) => { - await exportGroupConfig(groupId); - }, - [exportGroupConfig], - ); - - const handleStopSheet = useCallback( - async (sheetId: string) => { - if (confirm(t('process.confirmStop'))) { - await jobControl(sheetId, 'Stop'); - } - }, - [t, jobControl], - ); - - const handleSheetAction = useCallback( - async (sheetId: string, status: string) => { - const normalized = status.toLowerCase(); - if (normalized === 'paused') { - await jobControl(sheetId, 'Resume'); - return; - } - if (normalized === 'running' || normalized === 'pending') { - await jobControl(sheetId, 'Pause'); - } - }, - [jobControl], - ); - - const formatLogEntryWithLanguage = useCallback( - (entry: LogEntry, jobLabel?: string) => formatLogEntry(entry, jobLabel, language), - [language], - ); - - const formatTimeWithLanguage = useCallback( - (value?: string) => formatTime(value, language), - [language], - ); - - return { - t, - groups, - activeGroups, - hasProcessing, - expandedGroups, - expandedLogs, - collapsedRowGroups, - hasGroupConfig, - toggleGroup, - toggleLog, - toggleRowGroup, - handlePauseResumeAll, - handleStopAll, - handleOpenDashboard, - handleGroupAction, - handleStopGroup, - handleExportGroup, - handleStopSheet, - handleSheetAction, - formatLogEntry: formatLogEntryWithLanguage, - formatTime: formatTimeWithLanguage, - }; -}; diff --git a/frontend/src/features/process/index.ts b/frontend/src/features/process/index.ts deleted file mode 100644 index 0c673003..00000000 --- a/frontend/src/features/process/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from './ProcessMenu'; diff --git a/frontend/src/features/process/types/index.ts b/frontend/src/features/process/types/index.ts deleted file mode 100644 index c2182e70..00000000 --- a/frontend/src/features/process/types/index.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { SheetJob } from '@/shared/contexts/JobContextType'; -export type { LogEntry, RowLogGroup } from '@/shared/utils/job'; -import type { LogEntry, RowLogGroup } from '@/shared/utils/job'; - -export type TranslationFn = (key: string) => string; - -export interface SheetItemProps { - sheet: SheetJob; - showLog: boolean; - logGroups: RowLogGroup[]; - collapsedRowGroups: Record; - statusKey: (status: string) => string; - progressColor: (status: string) => string; - formatLogEntry: (entry: LogEntry, jobLabel?: string) => string; - onToggleLog: () => void; - onToggleRowGroup: (key: string) => void; - onSheetAction: () => void; - onStopSheet: () => void; - onCopyLogs: () => void; - t: TranslationFn; -} - -export interface ProcessHeaderProps { - hasProcessing: boolean; - activeGroupsCount: number; - onPauseResumeAll: () => void; - onStopAll: () => void; - onOpenDashboard: () => void; - t: TranslationFn; -} - -export interface ProcessGroupProps { - group: { - id: string; - status: string; - progress: number; - workbookPath: string; - createdAt?: string; - sheets: Record; - }; - showDetails: boolean; - expandedLogs: Record; - collapsedRowGroups: Record; - statusKey: (status: string) => string; - progressColor: (status: string) => string; - formatLogEntry: (entry: LogEntry, jobLabel?: string) => string; - formatTime: (value?: string) => string; - deriveGroupName: (workbookPath: string, fallback: string) => string; - hasGroupConfig: (groupId: string) => boolean; - onToggleGroup: () => void; - onToggleLog: (sheetId: string) => void; - onToggleRowGroup: (key: string) => void; - onGroupAction: () => void; - onStopGroup: () => void; - onExportGroup: () => void; - onSheetAction: (sheetId: string, status: string) => void; - onStopSheet: (sheetId: string) => void; - onCopyLogs: (sheet: SheetJob) => void; - t: TranslationFn; -} diff --git a/frontend/src/features/process/utils/index.ts b/frontend/src/features/process/utils/index.ts deleted file mode 100644 index d83ff63b..00000000 --- a/frontend/src/features/process/utils/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -export { - statusKey, - progressColor, - deriveGroupName, - formatLogEntry, - groupLogsByRow, - getSheetStats, - summarizeSheets, - formatTime, - type LogEntry, - type RowLogGroup, -} from '@/shared/utils/job'; diff --git a/frontend/src/features/results/ResultMenu.css b/frontend/src/features/results/ResultMenu.css deleted file mode 100644 index a1c0e027..00000000 --- a/frontend/src/features/results/ResultMenu.css +++ /dev/null @@ -1,521 +0,0 @@ -.output-menu { - max-width: 1040px; - margin: 0 auto; - display: flex; - flex-direction: column; - gap: var(--spacing-2xl); - animation: fadeInUp var(--transition-base); -} - -.output-menu .output-section { - padding: var(--spacing-2xl); - background-color: var(--bg-tertiary); - border-radius: var(--radius-lg); - border: 1px solid var(--border-primary); - box-shadow: var(--shadow-sm); -} - -.output-menu .output-list { - display: flex; - flex-direction: column; - gap: var(--spacing-lg); -} - -.output-menu .output-group { - background-color: var(--bg-secondary); - border: 1px solid var(--border-primary); - border-radius: var(--radius-lg); - overflow: hidden; - transition: - border-color var(--transition-fast), - box-shadow var(--transition-fast); -} - -.output-menu .output-group:hover { - border-color: var(--accent-primary); - box-shadow: var(--shadow-sm); -} - -.output-menu .group-header { - display: flex; - justify-content: space-between; - align-items: center; - padding: var(--spacing-lg); - cursor: pointer; - transition: background-color var(--transition-fast); -} - -.output-menu .group-header:hover { - background-color: var(--bg-hover); -} - -.output-menu .group-main-info { - display: flex; - align-items: center; - gap: var(--spacing-md); - flex: 1; -} - -.output-menu .expand-icon { - width: 12px; - height: 12px; -} - -.output-menu .file-expand-icon, -.output-menu .log-row-toggle { - width: 10px; - height: 10px; -} - -.output-menu .expand-icon, -.output-menu .file-expand-icon, -.output-menu .log-row-toggle { - object-fit: contain; - display: inline-block; - filter: brightness(0) invert(1); - transition: transform var(--transition-fast); - transform: rotate(-90deg); -} - -.output-menu .expand-icon.expanded, -.output-menu .file-expand-icon.expanded, -.output-menu .log-row-toggle.expanded { - transform: rotate(0deg); -} - -.output-menu .group-info { - flex: 1; -} - -.output-menu .group-name { - font-size: var(--font-lg); - font-weight: var(--font-semibold); - color: var(--text-primary); - margin-bottom: var(--spacing-xs); -} - -.output-menu .group-name-row { - display: flex; - align-items: baseline; - gap: var(--spacing-sm); - flex-wrap: wrap; -} - -.output-menu .group-time { - font-size: var(--font-xs); - color: var(--text-tertiary); - font-family: var(--font-mono); -} - -.output-menu .group-stats-line { - display: flex; - align-items: center; - gap: var(--spacing-sm); - color: var(--text-tertiary); - font-size: var(--font-sm); - flex-wrap: wrap; -} - -.output-menu .stat-badge { - padding: 4px 10px; - border-radius: var(--radius-full); - font-size: 11px; - font-weight: var(--font-semibold); -} - -.output-menu .stat-success { - background-color: rgba(16, 185, 129, 0.14); - color: #10b981; -} - -.output-menu .stat-failed { - background-color: rgba(239, 68, 68, 0.14); - color: #ef4444; -} - -.output-menu .stat-divider { - color: var(--text-tertiary); -} - -.output-menu .group-actions { - display: flex; - gap: var(--spacing-sm); -} - -.output-menu .output-btn, -.output-menu .output-btn-danger { - padding: 8px 12px; - border-radius: var(--radius-md); - border: none; - color: #fff; - font-weight: var(--font-semibold); - cursor: pointer; - display: inline-flex; - align-items: center; - gap: var(--spacing-sm); - transition: - transform var(--transition-fast), - box-shadow var(--transition-fast); -} - -.output-menu .output-btn:disabled { - opacity: 0.6; - cursor: not-allowed; - transform: none; - box-shadow: none; -} - -.output-menu .output-btn { - background: linear-gradient(135deg, var(--accent-primary), var(--accent-hover)); -} - -.output-menu .output-btn-danger { - background: linear-gradient(135deg, var(--danger-primary), var(--danger-hover)); -} - -.output-menu .output-btn:hover, -.output-menu .output-btn-danger:hover { - transform: translateY(-1px); - box-shadow: var(--shadow-sm); -} - -.output-menu .output-btn.output-btn-icon-only { - padding: 8px; - border: none; - background: transparent; - color: inherit; - box-shadow: none; -} - -.output-menu .output-btn.output-btn-icon-only:hover { - transform: none; - box-shadow: none; - background: transparent; -} - -.output-menu .files-list { - padding: var(--spacing-lg); - background-color: var(--bg-tertiary); - border-top: 1px solid var(--border-primary); - display: flex; - flex-direction: column; - gap: var(--spacing-md); -} - -.output-menu .file-item { - background-color: var(--bg-secondary); - border: 1px solid var(--border-primary); - border-radius: var(--radius-md); - padding: var(--spacing-lg); -} - -.output-menu .file-header-clickable { - display: flex; - justify-content: space-between; - align-items: center; - gap: var(--spacing-md); - cursor: pointer; -} - -.output-menu .file-info { - flex: 1; -} - -.output-menu .file-name { - font-size: var(--font-base); - font-weight: var(--font-semibold); - font-family: var(--font-mono); - color: var(--text-primary); - margin-bottom: var(--spacing-xs); -} - -.output-menu .file-stats { - display: flex; - align-items: center; - gap: var(--spacing-xs); - color: var(--text-tertiary); - font-size: var(--font-sm); - flex-wrap: wrap; -} - -.output-menu .file-stat-badge { - padding: 2px 8px; - border-radius: var(--radius-full); - font-size: 10px; - font-weight: var(--font-semibold); -} - -.output-menu .file-stat-badge.stat-success { - background-color: rgba(16, 185, 129, 0.14); - color: #10b981; -} - -.output-menu .file-stat-badge.stat-failed { - background-color: rgba(239, 68, 68, 0.14); - color: #ef4444; -} - -.output-menu .file-progress-text { - color: var(--text-tertiary); -} - -.output-menu .file-status { - padding: 4px 10px; - border-radius: var(--radius-full); - font-size: 10px; - font-weight: var(--font-semibold); - text-transform: uppercase; - background-color: rgba(16, 185, 129, 0.14); - color: #10b981; - margin-top: -2px; -} - -.output-menu .file-status[data-status='pending'] { - background-color: rgba(148, 163, 184, 0.18); - color: #94a3b8; -} - -.output-menu .file-status[data-status='paused'] { - background-color: rgba(245, 158, 11, 0.14); - color: #f59e0b; -} - -.output-menu .file-status[data-status='failed'], -.output-menu .file-status[data-status='error'], -.output-menu .file-status[data-status='cancelled'] { - background-color: rgba(239, 68, 68, 0.14); - color: #ef4444; -} - -.output-menu .file-actions { - display: flex; - flex-wrap: wrap; - gap: var(--spacing-sm); - margin: var(--spacing-sm) 0; -} - -.output-menu .file-status-and-actions { - display: flex; - align-items: flex-start; - gap: var(--spacing-sm); - flex-shrink: 0; -} - -.output-menu .file-action-buttons { - display: flex; - gap: var(--spacing-xs); -} - -.output-menu .file-action-btn { - width: 32px; - height: 32px; - border-radius: var(--radius-md); - border: 1px solid var(--border-primary); - background-color: var(--bg-tertiary); - display: grid; - place-items: center; - cursor: pointer; - transition: - border-color var(--transition-fast), - transform var(--transition-fast); -} - -.output-menu .file-action-btn:hover:not(:disabled) { - border-color: var(--accent-primary); - transform: translateY(-1px); -} - -.output-menu .file-action-btn:disabled { - opacity: 0.6; - cursor: not-allowed; - transform: none; -} - -.output-menu .file-action-btn-danger { - border-color: rgba(239, 68, 68, 0.35); - background-color: rgba(239, 68, 68, 0.12); -} - -.output-menu .file-btn { - padding: 8px 12px; - border-radius: var(--radius-md); - border: 1px solid var(--border-primary); - background-color: var(--bg-tertiary); - color: var(--text-primary); - font-weight: var(--font-semibold); - cursor: pointer; - display: inline-flex; - align-items: center; - gap: var(--spacing-sm); - transition: - border-color var(--transition-fast), - transform var(--transition-fast); -} - -.output-menu .file-btn:hover:not(:disabled) { - border-color: var(--accent-primary); - transform: translateY(-1px); -} - -.output-menu .file-btn:disabled { - opacity: 0.6; - cursor: not-allowed; -} - -.output-menu .file-btn-danger { - border-color: rgba(239, 68, 68, 0.35); - background-color: rgba(239, 68, 68, 0.12); - color: #ef4444; -} - -.output-menu .file-btn-icon { - width: 14px; - height: 14px; - object-fit: contain; - filter: brightness(0) invert(1); -} - -[data-theme='light'] .output-menu .file-btn-icon { - filter: none; -} - -.output-menu .file-log-content { - border: 1px solid var(--border-primary); - border-radius: var(--radius-md); - overflow: hidden; - margin-top: var(--spacing-lg); -} - -.output-menu .log-header { - display: flex; - justify-content: space-between; - align-items: center; - padding: var(--spacing-sm) var(--spacing-md); - background-color: var(--bg-tertiary); - border-bottom: 1px solid var(--border-primary); - font-size: var(--font-sm); - color: var(--text-secondary); - font-weight: var(--font-semibold); -} - -.output-menu .copy-log-btn { - background: transparent; - border: none; - padding: 4px; - cursor: pointer; -} - -.output-menu .log-icon { - width: 16px; - height: 16px; - object-fit: contain; - filter: brightness(0) invert(1); -} - -[data-theme='light'] .output-menu .log-icon { - filter: none; -} - -.output-menu .log-content { - max-height: 200px; - overflow-y: auto; - padding: var(--spacing-sm) var(--spacing-md); - background-color: var(--bg-secondary); -} - -.output-menu .log-row-group { - border: 1px solid var(--border-secondary); - border-radius: var(--radius-sm); - margin-bottom: var(--spacing-sm); - overflow: hidden; - background-color: var(--bg-tertiary); -} - -.output-menu .log-row-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 6px 10px; - font-size: 11px; - font-weight: var(--font-semibold); - text-transform: uppercase; - letter-spacing: 0.04em; - background-color: rgba(148, 163, 184, 0.15); - color: var(--text-secondary); - cursor: pointer; - gap: var(--spacing-sm); -} - -[data-theme='light'] .output-menu .expand-icon, -[data-theme='light'] .output-menu .file-expand-icon, -[data-theme='light'] .output-menu .log-row-toggle { - filter: none; -} - -.output-menu .log-row-title { - flex: 1; -} - -.output-menu .log-row-group[data-status='processing'] .log-row-header { - background-color: rgba(59, 130, 246, 0.16); - color: #3b82f6; -} - -.output-menu .log-row-group[data-status='completed'] .log-row-header { - background-color: rgba(16, 185, 129, 0.16); - color: #10b981; -} - -.output-menu .log-row-group[data-status='warning'] .log-row-header { - background-color: rgba(245, 158, 11, 0.2); - color: #f59e0b; -} - -.output-menu .log-row-group[data-status='error'] .log-row-header { - background-color: rgba(239, 68, 68, 0.2); - color: #ef4444; -} - -.output-menu .log-row-entries { - padding: 4px 10px; -} - -.output-menu .log-entry { - font-size: var(--font-sm); - color: var(--text-secondary); - font-family: var(--font-mono); - padding: 6px 8px; - border-radius: var(--radius-sm); - margin-bottom: 6px; - background-color: rgba(148, 163, 184, 0.1); -} - -.output-menu .log-entry:last-child { - margin-bottom: 0; -} - -.output-menu .log-entry.log-info { - background-color: rgba(59, 130, 246, 0.12); -} - -.output-menu .log-entry.log-warning { - background-color: rgba(245, 158, 11, 0.18); -} - -.output-menu .log-entry.log-error { - background-color: rgba(239, 68, 68, 0.16); -} - -.output-menu .log-empty { - text-align: center; - font-size: var(--font-sm); - color: var(--text-tertiary); - padding: var(--spacing-sm) 0; -} - -@media (max-width: 900px) { - .output-menu .menu-header { - flex-direction: column; - align-items: flex-start; - } -} diff --git a/frontend/src/features/results/ResultMenu.tsx b/frontend/src/features/results/ResultMenu.tsx deleted file mode 100644 index 7c31604a..00000000 --- a/frontend/src/features/results/ResultMenu.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import React from 'react'; -import type { SheetJob } from '@/shared/contexts/JobContextType'; -import type { LogEntry } from './types'; -import { useResults } from './hooks'; -import { ResultGroup, ResultHeader } from './components'; -import './ResultMenu.css'; - -const ResultMenu: React.FC = () => { - const results = useResults(); - - const handleCopyLogs = (sheet: SheetJob) => { - const logJobLabel = sheet.hangfireJobId ? `#${sheet.hangfireJobId}` : sheet.id; - navigator.clipboard.writeText( - sheet.logs.map((entry) => results.formatLogEntry(entry as LogEntry, logJobLabel)).join('\n'), - ); - }; - - return ( -
- - -
- {results.completedGroups.length === 0 ? ( -
{results.t('results.empty')}
- ) : ( -
- {results.completedGroups.map((group) => ( - results.toggleGroup(group.id)} - onToggleLog={results.toggleLog} - onToggleRowGroup={results.toggleRowGroup} - onOpenFolder={() => results.handleOpenFolder(group.outputFolder)} - onRemoveGroup={() => results.handleRemoveGroup(group.id)} - onExportGroup={() => results.handleExportGroup(group.id)} - onOpenFile={results.handleOpenFile} - onRemoveSheet={results.handleRemoveSheet} - onCopyLogs={handleCopyLogs} - t={results.t} - /> - ))} -
- )} -
-
- ); -}; - -export default ResultMenu; diff --git a/frontend/src/features/results/__tests__/ResultMenu.test.tsx b/frontend/src/features/results/__tests__/ResultMenu.test.tsx deleted file mode 100644 index e8be0c65..00000000 --- a/frontend/src/features/results/__tests__/ResultMenu.test.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import ResultMenu from '../ResultMenu'; - -const clearCompleted = vi.fn(); -const removeSheet = vi.fn(); -const removeGroup = vi.fn(); -const exportGroupConfig = vi.fn(); - -vi.mock('@/shared/contexts/useApp', () => ({ - useApp: () => ({ t: (key: string) => key }), -})); - -vi.mock('@/shared/contexts/useJobs', () => ({ - useJobs: () => ({ - groups: [ - { - id: 'group-2', - workbookPath: 'C:\\book.xlsx', - outputFolder: 'C:\\out', - status: 'Completed', - progress: 100, - errorCount: 0, - sheets: { - 'sheet-2': { - id: 'sheet-2', - sheetName: 'Sheet2', - status: 'Completed', - currentRow: 2, - totalRows: 2, - progress: 100, - errorCount: 0, - outputPath: 'C:\\out\\Sheet2.pptx', - logs: [ - { - message: 'Row 1 completed (text: 1, images: 1, image errors: 0)', - level: 'Info', - row: 1, - rowStatus: 'completed', - timestamp: new Date('2025-01-01T10:00:00Z').toISOString(), - }, - ], - }, - }, - logs: [], - }, - ], - clearCompleted, - removeGroup, - removeSheet, - loadSheetLogs: vi.fn(), - exportGroupConfig, - hasGroupConfig: () => true, - }), -})); - -describe('ResultMenu', () => { - beforeEach(() => { - vi.restoreAllMocks(); - }); - - it('shows row groups and sheet actions', async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByText('book.xlsx')); - await user.click(screen.getByText('Sheet2')); - - expect(screen.getByText('Row 1')).toBeInTheDocument(); - expect(screen.getAllByLabelText('results.open').length).toBeGreaterThan(0); - expect(screen.getByLabelText('results.remove')).toBeInTheDocument(); - }); - - it('confirms before clearing all and removing groups', async () => { - const user = userEvent.setup(); - const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true); - render(); - - await user.click(screen.getByText('results.clearAll')); - await user.click(screen.getByText('results.removeGroup')); - - expect(confirmSpy).toHaveBeenCalled(); - expect(clearCompleted).toHaveBeenCalled(); - expect(removeGroup).toHaveBeenCalled(); - }); -}); diff --git a/frontend/src/features/results/components/ResultGroup.tsx b/frontend/src/features/results/components/ResultGroup.tsx deleted file mode 100644 index 3957cf14..00000000 --- a/frontend/src/features/results/components/ResultGroup.tsx +++ /dev/null @@ -1,148 +0,0 @@ -import React from 'react'; -import { getAssetPath } from '@/shared/utils/paths'; -import type { SheetJob } from '@/shared/contexts/JobContextType'; -import type { LogEntry, RowLogGroup, TranslationFn } from '../types'; -import { deriveGroupName, groupLogsByRow, statusKey, summarizeSheets } from '../utils'; -import { ResultSheetItem } from './ResultSheetItem'; - -interface ResultGroupProps { - group: { - id: string; - status: string; - progress: number; - workbookPath: string; - completedAt?: string; - outputFolder?: string; - sheets: Record; - }; - showDetails: boolean; - expandedLogs: Record; - collapsedRowGroups: Record; - hasGroupConfig: (groupId: string) => boolean; - formatLogEntry: (entry: LogEntry, jobLabel?: string) => string; - formatTime: (value?: string) => string; - onToggleGroup: () => void; - onToggleLog: (sheetId: string) => void; - onToggleRowGroup: (key: string) => void; - onOpenFolder: () => void; - onRemoveGroup: () => void; - onExportGroup: () => void; - onOpenFile: (filePath?: string) => void; - onRemoveSheet: (sheetId: string) => void; - onCopyLogs: (sheet: SheetJob) => void; - t: TranslationFn; -} - -export const ResultGroup: React.FC = ({ - group, - showDetails, - expandedLogs, - collapsedRowGroups, - hasGroupConfig, - formatLogEntry, - formatTime, - onToggleGroup, - onToggleLog, - onToggleRowGroup, - onOpenFolder, - onRemoveGroup, - onExportGroup, - onOpenFile, - onRemoveSheet, - onCopyLogs, - t, -}) => { - const sheets = Object.values(group.sheets); - const { completedSlides, failedSlides, totalSlides } = summarizeSheets(sheets); - const groupProgress = group.progress; - const groupName = deriveGroupName(group.workbookPath, group.id); - - return ( -
-
-
- -
-
-
{groupName}
- {group.completedAt && ( - - {t('results.completedAt')}: {formatTime(group.completedAt)} - - )} -
-
- - {completedSlides}/{totalSlides} {t('process.slides')} - {Math.round(groupProgress)}% - - - {completedSlides} - - | - - {failedSlides} - -
-
-
-
e.stopPropagation()}> - - - -
-
- - {showDetails && ( -
- {sheets.map((sheet) => { - const showLog = expandedLogs[sheet.id] ?? false; - const logGroups: RowLogGroup[] = showLog - ? groupLogsByRow(sheet.logs as LogEntry[]) - : []; - - return ( - onToggleLog(sheet.id)} - onToggleRowGroup={onToggleRowGroup} - onOpenFile={() => onOpenFile(sheet.outputPath)} - onRemoveSheet={() => onRemoveSheet(sheet.id)} - onCopyLogs={() => onCopyLogs(sheet)} - t={t} - /> - ); - })} -
- )} -
- ); -}; diff --git a/frontend/src/features/results/components/ResultHeader.tsx b/frontend/src/features/results/components/ResultHeader.tsx deleted file mode 100644 index d7485f0b..00000000 --- a/frontend/src/features/results/components/ResultHeader.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import React from 'react'; -import { getAssetPath } from '@/shared/utils/paths'; -import type { ResultHeaderProps } from '../types'; - -export const ResultHeader: React.FC = ({ - completedGroupsCount, - onClearAll, - t, -}) => ( -
-

{t('results.title')}

-
- -
-
-); diff --git a/frontend/src/features/results/components/ResultSheetItem.tsx b/frontend/src/features/results/components/ResultSheetItem.tsx deleted file mode 100644 index 6e64847a..00000000 --- a/frontend/src/features/results/components/ResultSheetItem.tsx +++ /dev/null @@ -1,145 +0,0 @@ -import React from 'react'; -import { getAssetPath } from '@/shared/utils/paths'; -import type { ResultSheetItemProps } from '../types'; -import { getSheetStats, statusKey } from '../utils'; - -export const ResultSheetItem: React.FC = ({ - sheet, - showLog, - logGroups, - collapsedRowGroups, - formatLogEntry, - onToggleLog, - onToggleRowGroup, - onOpenFile, - onRemoveSheet, - onCopyLogs, - t, -}) => { - const { completedSlides, failedSlides } = getSheetStats(sheet); - const logJobLabel = sheet.hangfireJobId ? `#${sheet.hangfireJobId}` : sheet.id; - - return ( -
-
- -
-
{sheet.sheetName}
-
- - {completedSlides} - - | - - {failedSlides} - - - / {sheet.totalRows} {t('process.slides')} - {Math.round(sheet.progress)}% - -
-
-
-
- {t(`process.status.${statusKey(sheet.status)}`)} -
-
- - -
-
-
- - {showLog && ( -
-
- {t('process.log')} - -
-
- {sheet.logs.length === 0 ? ( -
{t('process.noLogs')}
- ) : ( - logGroups.map((group) => { - const rowKey = `${sheet.id}:${group.key}`; - const isCollapsed = collapsedRowGroups[rowKey] ?? true; - return ( -
-
onToggleRowGroup(rowKey)}> - - - {group.row != null ? `Row ${group.row}` : t('process.logGeneral')} - - {group.status && ( - {group.status.toUpperCase()} - )} -
- {!isCollapsed && ( -
- {group.entries.map((entry, index) => ( -
- {formatLogEntry(entry, logJobLabel)} -
- ))} -
- )} -
- ); - }) - )} -
-
- )} -
- ); -}; diff --git a/frontend/src/features/results/components/index.ts b/frontend/src/features/results/components/index.ts deleted file mode 100644 index 6019dfad..00000000 --- a/frontend/src/features/results/components/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { ResultSheetItem } from './ResultSheetItem'; -export { ResultHeader } from './ResultHeader'; -export { ResultGroup } from './ResultGroup'; diff --git a/frontend/src/features/results/hooks/index.ts b/frontend/src/features/results/hooks/index.ts deleted file mode 100644 index 9d57360e..00000000 --- a/frontend/src/features/results/hooks/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { useResults } from './useResults'; diff --git a/frontend/src/features/results/hooks/useResults.ts b/frontend/src/features/results/hooks/useResults.ts deleted file mode 100644 index 94e2c96a..00000000 --- a/frontend/src/features/results/hooks/useResults.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { useCallback, useMemo, useState } from 'react'; -import { useApp } from '@/shared/contexts/useApp'; -import { useJobs } from '@/shared/contexts/useJobs'; -import type { LogEntry } from '../types'; -import { formatLogEntry, formatTime } from '../utils'; - -export const useResults = () => { - const { t, language } = useApp(); - const { - groups, - clearCompleted, - removeGroup, - removeSheet, - loadSheetLogs, - exportGroupConfig, - hasGroupConfig, - } = useJobs(); - - const [expandedGroups, setExpandedGroups] = useState>({}); - const [expandedLogs, setExpandedLogs] = useState>({}); - const [collapsedRowGroups, setCollapsedRowGroups] = useState>({}); - - const completedGroups = useMemo( - () => - groups.filter((group) => - ['completed', 'failed', 'cancelled'].includes(group.status.toLowerCase()), - ), - [groups], - ); - - const toggleGroup = useCallback((groupId: string) => { - setExpandedGroups((prev) => ({ ...prev, [groupId]: !prev[groupId] })); - }, []); - - const toggleLog = useCallback( - (sheetId: string) => { - setExpandedLogs((prev) => { - const next = !prev[sheetId]; - if (next) { - void loadSheetLogs(sheetId); - } - return { ...prev, [sheetId]: next }; - }); - }, - [loadSheetLogs], - ); - - const toggleRowGroup = useCallback((key: string) => { - setCollapsedRowGroups((prev) => { - const current = prev[key] ?? true; - return { ...prev, [key]: !current }; - }); - }, []); - - const handleOpenFolder = useCallback(async (folderPath: string | undefined) => { - if (!folderPath || !window.electronAPI) return; - await window.electronAPI.openPath(folderPath); - }, []); - - const handleExportGroup = useCallback( - async (groupId: string) => { - await exportGroupConfig(groupId); - }, - [exportGroupConfig], - ); - - const handleOpenFile = useCallback(async (filePath: string | undefined) => { - if (!filePath || !window.electronAPI) return; - await window.electronAPI.openPath(filePath); - }, []); - - const handleRemoveSheet = useCallback( - async (sheetId: string) => { - await removeSheet(sheetId); - }, - [removeSheet], - ); - - const handleRemoveGroup = useCallback( - async (groupId: string) => { - if (confirm(t('results.confirmRemoveGroup'))) { - await removeGroup(groupId); - } - }, - [t, removeGroup], - ); - - const handleClearAll = useCallback(async () => { - if (confirm(t('results.confirmClearAll'))) { - await clearCompleted(); - } - }, [t, clearCompleted]); - - const formatLogEntryWithLanguage = useCallback( - (entry: LogEntry, jobLabel?: string) => formatLogEntry(entry, jobLabel, language), - [language], - ); - - const formatTimeWithLanguage = useCallback( - (value?: string) => formatTime(value, language), - [language], - ); - - return { - t, - completedGroups, - expandedGroups, - expandedLogs, - collapsedRowGroups, - hasGroupConfig, - toggleGroup, - toggleLog, - toggleRowGroup, - handleOpenFolder, - handleExportGroup, - handleOpenFile, - handleRemoveSheet, - handleRemoveGroup, - handleClearAll, - formatLogEntry: formatLogEntryWithLanguage, - formatTime: formatTimeWithLanguage, - }; -}; diff --git a/frontend/src/features/results/index.ts b/frontend/src/features/results/index.ts deleted file mode 100644 index 10b13dd8..00000000 --- a/frontend/src/features/results/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from './ResultMenu'; diff --git a/frontend/src/features/results/types/index.ts b/frontend/src/features/results/types/index.ts deleted file mode 100644 index 29d3f943..00000000 --- a/frontend/src/features/results/types/index.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { SheetJob } from '@/shared/contexts/JobContextType'; -export type { LogEntry, RowLogGroup } from '@/shared/utils/job'; -import type { LogEntry, RowLogGroup } from '@/shared/utils/job'; - -export type TranslationFn = (key: string) => string; - -export interface ResultSheetItemProps { - sheet: SheetJob; - showLog: boolean; - logGroups: RowLogGroup[]; - collapsedRowGroups: Record; - statusKey: (status: string) => string; - formatLogEntry: (entry: LogEntry, jobLabel?: string) => string; - onToggleLog: () => void; - onToggleRowGroup: (key: string) => void; - onOpenFile: () => void; - onRemoveSheet: () => void; - onCopyLogs: () => void; - t: TranslationFn; -} - -export interface ResultHeaderProps { - completedGroupsCount: number; - onClearAll: () => void; - t: TranslationFn; -} - -export interface ResultGroupProps { - group: { - id: string; - status: string; - progress: number; - workbookPath: string; - completedAt?: string; - outputFolder?: string; - sheets: Record; - }; - showDetails: boolean; - expandedLogs: Record; - collapsedRowGroups: Record; - hasGroupConfig: (groupId: string) => boolean; - formatLogEntry: (entry: LogEntry, jobLabel?: string) => string; - formatTime: (value?: string) => string; - onToggleGroup: () => void; - onToggleLog: (sheetId: string) => void; - onToggleRowGroup: (key: string) => void; - onOpenFolder: () => void; - onRemoveGroup: () => void; - onExportGroup: () => void; - onOpenFile: (filePath?: string) => void; - onRemoveSheet: (sheetId: string) => void; - onCopyLogs: (sheet: SheetJob) => void; - t: TranslationFn; -} diff --git a/frontend/src/features/results/utils/index.ts b/frontend/src/features/results/utils/index.ts deleted file mode 100644 index 5afd2e8f..00000000 --- a/frontend/src/features/results/utils/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -export { - statusKey, - deriveGroupName, - formatLogEntry, - groupLogsByRow, - getSheetStats, - summarizeSheetsSimple as summarizeSheets, - formatTime, - type LogEntry, - type RowLogGroup, -} from '@/shared/utils/job'; diff --git a/frontend/src/features/settings/SettingMenu.css b/frontend/src/features/settings/SettingMenu.css deleted file mode 100644 index fb9d7af0..00000000 --- a/frontend/src/features/settings/SettingMenu.css +++ /dev/null @@ -1,494 +0,0 @@ -.setting-menu { - max-width: 900px; - margin: 0 auto; - display: flex; - flex-direction: column; - gap: var(--spacing-2xl); - animation: fadeInUp var(--transition-base); -} - -.status-notification--closing { - animation: notificationOut 180ms ease forwards; -} - -.restart-notification__text { - color: var(--text-primary); -} - -.restart-notification__action { - margin-left: auto; - background-color: var(--bg-secondary); - border-color: var(--border-primary); - color: var(--text-primary); -} - -.restart-notification--closing { - animation: notificationOut 180ms ease forwards; -} - -.setting-tabs { - display: flex; - flex-wrap: wrap; - gap: var(--spacing-sm); -} - -.tab-button { - padding: 10px 16px; - border-radius: var(--radius-full); - border: 1px solid var(--border-primary); - background: var(--bg-tertiary); - color: var(--text-secondary); - font-weight: var(--font-semibold); - cursor: pointer; - transition: - background-color var(--transition-fast), - border-color var(--transition-fast), - color var(--transition-fast); -} - -.tab-button:hover { - border-color: var(--accent-primary); - color: var(--text-primary); -} - -.tab-button.active { - background: var(--accent-soft); - border-color: var(--accent-primary); - color: var(--accent-primary); -} - -.setting-section { - padding: var(--spacing-2xl); - background-color: var(--bg-tertiary); - border: 1px solid var(--border-primary); - border-radius: var(--radius-lg); - box-shadow: var(--shadow-sm); - display: flex; - flex-direction: column; - gap: var(--spacing-lg); -} - -.setting-section--locked { - opacity: 0.4; - cursor: not-allowed; -} - -.setting-menu input:disabled, -.setting-menu select:disabled, -.setting-menu textarea:disabled, -.setting-menu button:disabled { - cursor: not-allowed; -} - -.toggle-switch input:disabled + .toggle-slider { - cursor: not-allowed; -} - -.setting-section h3 { - font-size: var(--font-xl); - font-weight: var(--font-semibold); - color: var(--text-primary); -} - -.setting-subsection-title { - font-size: var(--font-lg); - font-weight: var(--font-semibold); - color: var(--text-secondary); - margin-top: var(--spacing-md); - padding-top: var(--spacing-md); - border-top: 1px solid var(--border-primary); -} - -.setting-label-inline { - display: flex; - align-items: center; - gap: var(--spacing-sm); - cursor: pointer; -} - -.setting-checkbox { - width: 18px; - height: 18px; - accent-color: var(--accent-primary); - cursor: pointer; -} - -.settings-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); - gap: var(--spacing-lg); -} - -.setting-item { - display: flex; - flex-direction: column; - gap: var(--spacing-xs); -} - -.setting-hint { - font-size: var(--font-sm); - color: var(--text-tertiary); -} - -.setting-item-toggle { - background: var(--bg-secondary); - border: 1px solid var(--border-primary); - border-radius: var(--radius-md); - padding: var(--spacing-lg); -} - -.toggle-content { - display: flex; - align-items: center; - gap: var(--spacing-lg); -} - -.toggle-label { - display: flex; - flex-direction: column; - gap: var(--spacing-xs); - color: var(--text-secondary); - flex: 1 1 auto; - min-width: 0; -} - -.label-text { - font-weight: var(--font-semibold); - color: var(--text-primary); -} - -.label-description { - font-size: var(--font-sm); - color: var(--text-tertiary); -} - -.toggle-switch { - position: relative; - width: 52px; - height: 28px; - display: inline-flex; - align-items: center; - justify-content: center; - margin-left: auto; -} - -.toggle-switch input { - opacity: 0; - width: 0; - height: 0; -} - -.toggle-slider { - position: absolute; - inset: 0; - background-color: var(--bg-hover); - border-radius: 999px; - border: 1px solid var(--border-primary); - transition: - background-color var(--transition-fast), - border-color var(--transition-fast); - cursor: pointer; - box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08); -} - -.toggle-slider::before { - content: ''; - position: absolute; - width: 20px; - height: 20px; - left: 4px; - top: 3px; - background: #fff; - border-radius: 50%; - transition: transform var(--transition-fast); - box-shadow: var(--shadow-sm); -} - -.toggle-switch input:focus-visible + .toggle-slider { - outline: 2px solid var(--accent-primary); - outline-offset: 2px; -} - -.toggle-switch input:checked + .toggle-slider { - background-color: var(--accent-primary); - border-color: var(--accent-primary); -} - -.toggle-switch input:checked + .toggle-slider::before { - transform: translateX(24px); -} - -.setting-input-with-display, -.setting-input-with-unit { - display: flex; - align-items: center; - gap: var(--spacing-sm); -} - -.setting-display, -.input-unit { - padding: 8px 12px; - border-radius: var(--radius-md); - background-color: var(--bg-secondary); - border: 1px solid var(--border-primary); - color: var(--text-secondary); - font-size: var(--font-sm); - font-weight: var(--font-semibold); - min-width: 72px; - text-align: center; -} - -.setting-actions { - display: flex; - justify-content: flex-end; - gap: var(--spacing-md); -} - -.image-config-block { - display: flex; - flex-direction: column; - gap: var(--spacing-lg); - padding: var(--spacing-lg); - background: var(--bg-secondary); - border-radius: var(--radius-md); - border: 1px solid var(--border-primary); -} - -.image-config-header h4 { - margin: 0; - font-size: var(--font-lg); - color: var(--text-primary); -} - -.image-config-grid { - display: flex; - justify-content: center; - align-items: center; -} - -/* Padding section */ -.padding-section { - width: 100%; -} - -.padding-title { - font-size: var(--font-sm); - font-weight: 600; - color: var(--text-secondary); - margin-bottom: var(--spacing-md); - text-align: left; -} - -.image-padding-layout { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr); - grid-template-rows: auto auto auto; - gap: var(--spacing-md); - align-items: center; - justify-items: center; - max-width: 600px; - margin: 0 auto; -} - -.pad-item { - display: flex; - flex-direction: column; - gap: var(--spacing-xs); - max-width: 180px; - align-items: center; -} - -.pad-top { - grid-column: 2; - max-width: 180px; -} - -.pad-left { - grid-column: 1; - grid-row: 2; -} - -.pad-center { - grid-column: 2; - grid-row: 2; - display: flex; - justify-content: center; -} - -.pad-right { - grid-column: 3; - grid-row: 2; -} - -.pad-bottom { - grid-column: 2; - grid-row: 3; - max-width: 180px; -} - -.pad-diagram { - position: relative; - width: 160px; - height: 120px; - border-radius: var(--radius-md); - background: rgba(0, 0, 0, 0.12); -} - -.pad-box { - position: absolute; - border-radius: var(--radius-sm); - transition: - top 160ms ease, - right 160ms ease, - bottom 160ms ease, - left 160ms ease; -} - -.pad-base { - inset: 10%; - background: rgba(0, 0, 0, 0.35); -} - -.pad-detect { - inset: 25%; - background: rgba(79, 156, 249, 0.4); - border: 1px solid rgba(79, 156, 249, 0.7); -} - -.pad-crop { - inset: 25%; - background: rgba(16, 185, 129, 0.35); - border: 1px solid rgba(16, 185, 129, 0.7); -} - -.image-config-side { - display: flex; - flex-direction: column; - gap: var(--spacing-lg); -} - -/* Face config row - Confidence and Union All side by side */ -.face-config-row { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); - gap: var(--spacing-lg); - margin-bottom: var(--spacing-lg); - align-items: start; -} - -.face-config-row .setting-item { - height: 100%; -} - -.face-config-row .setting-item-toggle { - display: flex; - flex-direction: column; - justify-content: center; - height: 100%; - min-height: 88px; -} - -/* Model table */ -.model-table-container { - margin-top: var(--spacing-md); -} - -.model-table-title { - font-size: var(--font-sm); - font-weight: 600; - color: var(--text-secondary); - margin-bottom: var(--spacing-sm); -} - -.model-table { - width: 100%; - border-collapse: collapse; - table-layout: fixed; - font-size: var(--font-sm); -} - -.model-table th, -.model-table td { - padding: var(--spacing-sm) var(--spacing-md); - text-align: left; -} - -.model-table th:nth-child(1), -.model-table td:nth-child(1) { - width: 50%; -} - -.model-table th:nth-child(2), -.model-table td:nth-child(2) { - width: 25%; - text-align: center; -} - -.model-table th:nth-child(3), -.model-table td:nth-child(3) { - width: 25%; - text-align: center; -} - -.model-table th { - font-weight: 600; - color: var(--text-secondary); - border-bottom: 1px solid var(--border-primary); -} - -.model-table td { - color: var(--text-primary); -} - -.model-table tbody tr:hover { - background: var(--bg-secondary); -} - -.model-status-badge { - display: inline-block; - padding: var(--spacing-xs) var(--spacing-sm); - border-radius: var(--radius-sm); - font-size: var(--font-sm); - font-weight: 500; -} - -.model-status-available { - background: rgba(16, 185, 129, 0.15); - color: var(--color-success); - border: 1px solid var(--color-success); -} - -.model-status-unavailable { - background: rgba(156, 163, 175, 0.15); - color: var(--text-muted); - border: 1px solid var(--border-primary); -} - -.btn-sm { - padding: var(--spacing-xs) var(--spacing-sm); - font-size: var(--font-sm); -} - -@media (max-width: 768px) { - .setting-actions { - flex-direction: column; - } - - .image-config-grid { - grid-template-columns: 1fr; - } - - .face-config-row { - grid-template-columns: 1fr; - } - - .pad-diagram { - width: 140px; - height: 110px; - } - - .model-table th, - .model-table td { - padding: var(--spacing-xs) var(--spacing-sm); - } -} diff --git a/frontend/src/features/settings/SettingMenu.tsx b/frontend/src/features/settings/SettingMenu.tsx deleted file mode 100644 index 658e0041..00000000 --- a/frontend/src/features/settings/SettingMenu.tsx +++ /dev/null @@ -1,215 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { useApp } from '@/shared/contexts/useApp'; -import { useJobs } from '@/shared/contexts/useJobs'; -import type { SettingTab } from './types'; -import { createPadStyles } from './utils'; -import { useSettings } from './hooks/useSettings'; -import { - SettingsNotifications, - SettingsTabs, - AppearanceTab, - ServerTab, - DownloadTab, - JobTab, - ImageTab, - SettingActions, -} from './components'; -import './SettingMenu.css'; - -const SettingMenu: React.FC = () => { - const { - theme, - language, - enableAnimations, - closeToTray, - setTheme, - setLanguage, - setEnableAnimations, - setCloseToTray, - t, - } = useApp(); - const { groups } = useJobs(); - const [activeTab, setActiveTab] = useState('appearance'); - - const { - config, - loading, - saving, - message, - restartRequired, - showRestartNotification, - isRestartNotificationClosing, - showStatusNotification, - isStatusNotificationClosing, - faceModelAvailable, - modelLoading, - - setShowRestartNotification, - setIsRestartNotificationClosing, - - handleNumberChange, - handleNumberBlur, - handleNumberFocus, - hideStatusNotification, - - loadConfig, - loadModelStatus, - saveConfig, - reloadConfig, - resetConfig, - handleRestartServer, - handleInitModel, - handleDeinitModel, - - updateServer, - updateDownload, - updateJob, - updateFace, - updateSaliency, - handleSelectDownloadFolder, - } = useSettings({ t }); - - useEffect(() => { - loadConfig().catch(() => undefined); - loadModelStatus().catch(() => undefined); - }, [loadConfig, loadModelStatus]); - - useEffect(() => { - if (restartRequired) { - setShowRestartNotification(true); - setIsRestartNotificationClosing(false); - return undefined; - } - - if (!showRestartNotification) return undefined; - setIsRestartNotificationClosing(true); - const timeoutId = window.setTimeout(() => { - setShowRestartNotification(false); - setIsRestartNotificationClosing(false); - }, 180); - return () => window.clearTimeout(timeoutId); - }, [ - restartRequired, - setIsRestartNotificationClosing, - setShowRestartNotification, - showRestartNotification, - ]); - - const isActiveStatus = (status: string) => ['pending', 'running'].includes(status.toLowerCase()); - const hasActiveJobs = groups.some((group) => { - if (isActiveStatus(group.status)) return true; - return Object.values(group.sheets).some((sheet) => isActiveStatus(sheet.status)); - }); - const isLocked = activeTab !== 'appearance' && hasActiveJobs; - const canEditConfig = !hasActiveJobs; - const isEditable = !loading && !!config && canEditConfig; - - return ( -
-

{t('settings.title')}

- - - - - - {activeTab === 'appearance' && ( - - )} - - {activeTab === 'server' && ( - - )} - - {activeTab === 'download' && ( - - )} - - {activeTab === 'job' && ( - - )} - - {activeTab === 'image' && ( - - )} - - -
- ); -}; - -export default SettingMenu; diff --git a/frontend/src/features/settings/components/AppearanceTab.tsx b/frontend/src/features/settings/components/AppearanceTab.tsx deleted file mode 100644 index 10ff4091..00000000 --- a/frontend/src/features/settings/components/AppearanceTab.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import React from 'react'; -import type { Theme } from '@/shared/contexts/AppContextType'; -import type { Language } from '@/shared/locales'; -import type { AppearanceTabProps } from '../types'; - -export const AppearanceTab: React.FC = ({ - theme, - language, - enableAnimations, - closeToTray, - setTheme, - setLanguage, - setEnableAnimations, - setCloseToTray, - t, -}) => ( -
-

{t('settings.appearanceSettings')}

- -
-
- - - {t('settings.themeHint')} -
- -
- - - {t('settings.languageHint')} -
-
- -
-
-
-
{t('settings.enableAnimations')}
-
{t('settings.animationsDesc')}
-
- -
-
- -
-
-
-
{t('settings.closeToTray')}
-
{t('settings.closeToTrayDesc')}
-
- -
-
-
-); diff --git a/frontend/src/features/settings/components/DownloadTab.tsx b/frontend/src/features/settings/components/DownloadTab.tsx deleted file mode 100644 index 7da6d466..00000000 --- a/frontend/src/features/settings/components/DownloadTab.tsx +++ /dev/null @@ -1,226 +0,0 @@ -import React from 'react'; -import type { DownloadTabProps } from '../types'; - -export const DownloadTab: React.FC = ({ - loading, - config, - canEditConfig, - isLocked, - updateDownload, - handleNumberChange, - handleNumberBlur, - handleNumberFocus, - onSelectFolder, - t, -}) => ( -
-

{t('settings.downloadSettings')}

- {loading || !config ? ( -
{t('settings.loading')}
- ) : ( - <> -
- -
- updateDownload({ saveFolder: e.target.value })} - placeholder="./downloads" - /> - -
- {t('settings.saveFolderHint')} -
- -
-
- - - handleNumberChange(e.target.value, (next) => updateDownload({ maxChunks: next })) - } - onBlur={(e) => - handleNumberBlur(e.target.value, (next) => updateDownload({ maxChunks: next })) - } - onFocus={handleNumberFocus} - min="1" - max="128" - /> - {t('settings.maxChunksHint')} -
- -
- - - handleNumberChange(e.target.value, (next) => - updateDownload({ limitBytesPerSecond: next }), - ) - } - onBlur={(e) => - handleNumberBlur(e.target.value, (next) => - updateDownload({ limitBytesPerSecond: next }), - ) - } - onFocus={handleNumberFocus} - min="0" - /> - {t('settings.speedLimitHint')} -
-
- -
-
- - - handleNumberChange(e.target.value, (next) => updateDownload({ retryTimeout: next })) - } - onBlur={(e) => - handleNumberBlur(e.target.value, (next) => updateDownload({ retryTimeout: next })) - } - onFocus={handleNumberFocus} - min="1" - /> - {t('settings.retryTimeoutHint')} -
- -
- - - handleNumberChange(e.target.value, (next) => updateDownload({ maxRetries: next })) - } - onBlur={(e) => - handleNumberBlur(e.target.value, (next) => updateDownload({ maxRetries: next })) - } - onFocus={handleNumberFocus} - min="0" - max="10" - /> - {t('settings.maxRetriesHint')} -
-
- -

{t('settings.proxySettings')}

- -
- - {t('settings.useProxyHint')} -
- - {config.download.proxy.useProxy && ( - <> -
- - - updateDownload({ - proxy: { ...config.download.proxy, proxyAddress: e.target.value }, - }) - } - placeholder="http://proxy.example.com:8080" - /> - {t('settings.proxyAddressHint')} -
- -
-
- - - updateDownload({ - proxy: { ...config.download.proxy, username: e.target.value }, - }) - } - placeholder={t('settings.optional')} - /> -
- -
- - - updateDownload({ - proxy: { ...config.download.proxy, password: e.target.value }, - }) - } - placeholder={t('settings.optional')} - /> -
-
- -
- - - updateDownload({ - proxy: { ...config.download.proxy, domain: e.target.value }, - }) - } - placeholder={t('settings.optional')} - /> - {t('settings.proxyDomainHint')} -
- - )} - - )} -
-); diff --git a/frontend/src/features/settings/components/ImageTab.tsx b/frontend/src/features/settings/components/ImageTab.tsx deleted file mode 100644 index e36e5549..00000000 --- a/frontend/src/features/settings/components/ImageTab.tsx +++ /dev/null @@ -1,296 +0,0 @@ -import React from 'react'; -import type { ImageTabProps } from '../types'; - -export const ImageTab: React.FC = ({ - loading, - config, - canEditConfig, - isLocked, - faceModelAvailable, - modelLoading, - onInitModel, - onDeinitModel, - updateFace, - updateSaliency, - createPadStyles, - handleNumberChange, - handleNumberBlur, - handleNumberFocus, - t, -}) => ( -
-

{t('settings.imageSettings')}

- {loading || !config ? ( -
{t('settings.loading')}
- ) : ( - <> -
-
-
-

{t('settings.imageFace')}

- {t('settings.imageFaceHint')} -
-
-
-
- - - handleNumberChange(e.target.value, (next) => updateFace({ confidence: next })) - } - onBlur={(e) => - handleNumberBlur(e.target.value, (next) => updateFace({ confidence: next })) - } - onFocus={handleNumberFocus} - min="0" - max="1" - step="0.01" - /> - {t('settings.imagePaddingHint')} -
-
- - - handleNumberChange(e.target.value, (next) => updateFace({ maxDimension: next })) - } - onBlur={(e) => - handleNumberBlur(e.target.value, (next) => updateFace({ maxDimension: next })) - } - onFocus={handleNumberFocus} - min="0" - step="1" - /> - {t('settings.imageMaxDimensionHint')} -
-
- -
-
-
-
-
{t('settings.imageUnionAll')}
-
{t('settings.imageUnionAllDesc')}
-
- -
-
-
- - {/* Model table */} -
-
{t('settings.imageModel')}
- - - - - - - - - - - - - - - -
{t('settings.modelName')}{t('settings.modelstatus')}{t('settings.modelAction')}
{t('settings.faceModel')} - - {faceModelAvailable - ? t('settings.modelAvailable') - : t('settings.modelUnavailable')} - - - {faceModelAvailable ? ( - - ) : ( - - )} -
-
-
- -
-
-
-

{t('settings.imageSaliency')}

- {t('settings.imageSaliencyHint')} -
-
-
-
-
{t('settings.imagePadding')}
-
-
- - - handleNumberChange(e.target.value, (next) => - updateSaliency({ paddingTop: next }), - ) - } - onBlur={(e) => - handleNumberBlur(e.target.value, (next) => - updateSaliency({ paddingTop: next }), - ) - } - onFocus={handleNumberFocus} - min="0" - max="1" - step="0.01" - /> - {t('settings.imagePaddingHint')} -
-
- - - handleNumberChange(e.target.value, (next) => - updateSaliency({ paddingLeft: next }), - ) - } - onBlur={(e) => - handleNumberBlur(e.target.value, (next) => - updateSaliency({ paddingLeft: next }), - ) - } - onFocus={handleNumberFocus} - min="0" - max="1" - step="0.01" - /> - {t('settings.imagePaddingHint')} -
-
-
-
-
-
-
-
-
- - - handleNumberChange(e.target.value, (next) => - updateSaliency({ paddingRight: next }), - ) - } - onBlur={(e) => - handleNumberBlur(e.target.value, (next) => - updateSaliency({ paddingRight: next }), - ) - } - onFocus={handleNumberFocus} - min="0" - max="1" - step="0.01" - /> - {t('settings.imagePaddingHint')} -
-
- - - handleNumberChange(e.target.value, (next) => - updateSaliency({ paddingBottom: next }), - ) - } - onBlur={(e) => - handleNumberBlur(e.target.value, (next) => - updateSaliency({ paddingBottom: next }), - ) - } - onFocus={handleNumberFocus} - min="0" - max="1" - step="0.01" - /> - {t('settings.imagePaddingHint')} -
-
-
-
-
- - )} -
-); diff --git a/frontend/src/features/settings/components/JobTab.tsx b/frontend/src/features/settings/components/JobTab.tsx deleted file mode 100644 index 1b9f5585..00000000 --- a/frontend/src/features/settings/components/JobTab.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import React from 'react'; -import type { JobTabProps } from '../types'; - -export const JobTab: React.FC = ({ - loading, - config, - canEditConfig, - isLocked, - updateJob, - handleNumberChange, - handleNumberBlur, - handleNumberFocus, - t, -}) => ( -
-

{t('settings.jobSettings')}

- {loading || !config ? ( -
{t('settings.loading')}
- ) : ( -
- - - handleNumberChange(e.target.value, (next) => updateJob({ maxConcurrentJobs: next })) - } - onBlur={(e) => - handleNumberBlur(e.target.value, (next) => updateJob({ maxConcurrentJobs: next })) - } - onFocus={handleNumberFocus} - min="1" - max="32" - /> - {t('settings.maxConcurrentJobsHint')} -
- )} -
-); diff --git a/frontend/src/features/settings/components/ServerTab.tsx b/frontend/src/features/settings/components/ServerTab.tsx deleted file mode 100644 index 9f4d9608..00000000 --- a/frontend/src/features/settings/components/ServerTab.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import React from 'react'; -import type { ServerTabProps } from '../types'; - -export const ServerTab: React.FC = ({ - loading, - config, - canEditConfig, - isLocked, - updateServer, - handleNumberChange, - handleNumberBlur, - handleNumberFocus, - t, -}) => ( -
-

{t('settings.serverSettings')}

- {loading || !config ? ( -
{t('settings.loading')}
- ) : ( - <> -
-
- - updateServer({ host: e.target.value })} - placeholder="127.0.0.1" - /> - {t('settings.hostHint')} -
- -
- - - handleNumberChange(e.target.value, (next) => updateServer({ port: next })) - } - onBlur={(e) => - handleNumberBlur(e.target.value, (next) => updateServer({ port: next })) - } - onFocus={handleNumberFocus} - min="1" - max="65535" - /> - {t('settings.portHint')} -
-
- -
-
-
-
{t('settings.debugMode')}
-
{t('settings.debugModeDesc')}
-
- -
-
- - )} -
-); diff --git a/frontend/src/features/settings/components/SettingActions.tsx b/frontend/src/features/settings/components/SettingActions.tsx deleted file mode 100644 index 81a34405..00000000 --- a/frontend/src/features/settings/components/SettingActions.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import React from 'react'; -import type { SettingActionsProps } from '../types'; - -export const SettingActions: React.FC = ({ - saving, - isEditable, - showActions, - onSave, - onReload, - onReset, - t, -}) => { - if (!showActions) return null; - return ( -
- - - -
- ); -}; diff --git a/frontend/src/features/settings/components/SettingsNotifications.tsx b/frontend/src/features/settings/components/SettingsNotifications.tsx deleted file mode 100644 index c3dadfbc..00000000 --- a/frontend/src/features/settings/components/SettingsNotifications.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import React from 'react'; -import { getAssetPath } from '@/shared/utils/paths'; -import type { SettingsNotificationsProps } from '../types'; -import { splitNotificationText } from '../utils'; - -export const SettingsNotifications: React.FC = ({ - showRestartNotification, - isRestartNotificationClosing, - onRestart, - message, - showStatusNotification, - isStatusNotificationClosing, - onCloseStatus, - showLockedNotification, - t, -}) => ( - <> - {showRestartNotification && ( -
- {t('settings.restartRequired')} - -
- )} - {message && showStatusNotification && ( -
- {(() => { - const { title, detail } = splitNotificationText(message.text); - return ( - - {title} - {detail ? {detail} : null} - - ); - })()} - -
- )} - {showLockedNotification && ( -
{t('settings.locked')}
- )} - -); diff --git a/frontend/src/features/settings/components/SettingsTabs.tsx b/frontend/src/features/settings/components/SettingsTabs.tsx deleted file mode 100644 index b6a586f3..00000000 --- a/frontend/src/features/settings/components/SettingsTabs.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import React from 'react'; -import type { SettingsTabsProps } from '../types'; - -export const SettingsTabs: React.FC = ({ activeTab, onSelectTab, t }) => ( -
- - - - - -
-); diff --git a/frontend/src/features/settings/components/index.ts b/frontend/src/features/settings/components/index.ts deleted file mode 100644 index a08baad1..00000000 --- a/frontend/src/features/settings/components/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export { SettingsNotifications } from './SettingsNotifications'; -export { SettingsTabs } from './SettingsTabs'; -export { AppearanceTab } from './AppearanceTab'; -export { ServerTab } from './ServerTab'; -export { DownloadTab } from './DownloadTab'; -export { JobTab } from './JobTab'; -export { ImageTab } from './ImageTab'; -export { SettingActions } from './SettingActions'; diff --git a/frontend/src/features/settings/hooks/useSettings.ts b/frontend/src/features/settings/hooks/useSettings.ts deleted file mode 100644 index f8022de4..00000000 --- a/frontend/src/features/settings/hooks/useSettings.ts +++ /dev/null @@ -1,449 +0,0 @@ -import { useCallback, useRef, useState } from 'react'; -import * as backendApi from '@/shared/services/backendApi'; -import { loggers } from '@/shared/services/logging'; -import type { ConfigState } from '../types'; -import { - buildBackendUrl, - getErrorDetail, - normalizeBackendUrl, - parseConfigResponse, -} from '../utils'; - -const PENDING_BACKEND_URL_KEY = 'slidegen.backend.url.pending'; -const PENDING_BACKEND_URL_SESSION_KEY = 'slidegen.backend.url.pending.defer'; - -export interface UseSettingsOptions { - t: (key: string) => string; -} - -export const useSettings = ({ t }: UseSettingsOptions) => { - const [config, setConfig] = useState(null); - const [initialServer, setInitialServer] = useState(null); - const [initialJob, setInitialJob] = useState(null); - const [loading, setLoading] = useState(false); - const [saving, setSaving] = useState(false); - const [message, setMessage] = useState<{ - type: 'success' | 'error' | 'warning'; - text: string; - } | null>(null); - const [restartRequired, setRestartRequired] = useState(false); - const [showRestartNotification, setShowRestartNotification] = useState(false); - const [isRestartNotificationClosing, setIsRestartNotificationClosing] = useState(false); - const [showStatusNotification, setShowStatusNotification] = useState(false); - const [isStatusNotificationClosing, setIsStatusNotificationClosing] = useState(false); - const statusHideTimeoutRef = useRef(null); - const statusCloseTimeoutRef = useRef(null); - const [faceModelAvailable, setFaceModelAvailable] = useState(false); - const [modelLoading, setModelLoading] = useState(false); - - const formatErrorMessage = useCallback( - (key: string, error: unknown) => { - const detail = getErrorDetail(error); - return detail ? `${t(key)}: ${detail}` : t(key); - }, - [t], - ); - - const clearStatusTimeouts = useCallback(() => { - if (statusHideTimeoutRef.current) { - window.clearTimeout(statusHideTimeoutRef.current); - statusHideTimeoutRef.current = null; - } - if (statusCloseTimeoutRef.current) { - window.clearTimeout(statusCloseTimeoutRef.current); - statusCloseTimeoutRef.current = null; - } - }, []); - - const hideStatusNotification = useCallback(() => { - clearStatusTimeouts(); - setIsStatusNotificationClosing(true); - statusCloseTimeoutRef.current = window.setTimeout(() => { - setShowStatusNotification(false); - setIsStatusNotificationClosing(false); - setMessage(null); - statusCloseTimeoutRef.current = null; - }, 180); - }, [clearStatusTimeouts]); - - const showMessage = useCallback( - (type: 'success' | 'error' | 'warning', text: string) => { - clearStatusTimeouts(); - setMessage({ type, text }); - setShowStatusNotification(true); - setIsStatusNotificationClosing(false); - statusHideTimeoutRef.current = window.setTimeout(() => { - hideStatusNotification(); - statusHideTimeoutRef.current = null; - }, 5000); - }, - [clearStatusTimeouts, hideStatusNotification], - ); - - const handleNumberChange = useCallback((value: string, apply: (next: number) => void) => { - const next = value === '' ? Number.NaN : Number(value); - apply(next); - }, []); - - const handleNumberBlur = useCallback((value: string, apply: (next: number) => void) => { - if (value === '') apply(0); - }, []); - - const handleNumberFocus = useCallback((event: React.FocusEvent) => { - event.currentTarget.select(); - }, []); - - const storeBackendUrl = useCallback((host: string, port: number) => { - const url = buildBackendUrl(host, port); - if (!url) return; - localStorage.setItem('slidegen.backend.url', url); - }, []); - - const storePendingBackendUrl = useCallback((host: string, port: number) => { - const url = buildBackendUrl(host, port); - if (!url) return; - localStorage.setItem(PENDING_BACKEND_URL_KEY, url); - sessionStorage.setItem(PENDING_BACKEND_URL_SESSION_KEY, '1'); - }, []); - - const clearPendingBackendUrl = useCallback(() => { - localStorage.removeItem(PENDING_BACKEND_URL_KEY); - sessionStorage.removeItem(PENDING_BACKEND_URL_SESSION_KEY); - }, []); - - const hasPendingBackendUrl = useCallback(() => { - return Boolean(localStorage.getItem(PENDING_BACKEND_URL_KEY)); - }, []); - - const loadConfig = useCallback(async () => { - try { - setLoading(true); - const response = await backendApi.getConfig(); - const data = response as backendApi.ConfigGetSuccess; - const { config: nextConfig, server } = parseConfigResponse(data); - setConfig(nextConfig); - setInitialServer(server); - setInitialJob(nextConfig.job); - const pendingRestart = hasPendingBackendUrl(); - setRestartRequired(pendingRestart); - if (!pendingRestart) { - storeBackendUrl(server.host, server.port); - } - } catch (error) { - loggers.settings.error('Failed to load config:', error); - showMessage('error', formatErrorMessage('settings.loadError', error)); - } finally { - setLoading(false); - } - }, [formatErrorMessage, hasPendingBackendUrl, showMessage, storeBackendUrl]); - - const loadModelStatus = useCallback(async () => { - try { - const response = await backendApi.getModelStatus(); - const available = response.faceModelAvailable; - setFaceModelAvailable(available); - return available; - } catch (error) { - loggers.settings.error('Failed to load model status:', error); - return undefined; - } - }, []); - - const handleInitModel = useCallback(async () => { - try { - setModelLoading(true); - const response = await backendApi.controlModel('face', 'init'); - const available = await loadModelStatus(); - const isAvailable = available ?? response.success; - if (isAvailable) { - showMessage('success', t('settings.modelInitSuccess')); - } else { - showMessage('error', response.message ?? t('settings.modelInitError')); - } - } catch (error) { - loggers.settings.error('Failed to init model:', error); - showMessage('error', formatErrorMessage('settings.modelInitError', error)); - } finally { - setModelLoading(false); - } - }, [formatErrorMessage, loadModelStatus, showMessage, t]); - - const handleDeinitModel = useCallback(async () => { - try { - setModelLoading(true); - const response = await backendApi.controlModel('face', 'deinit'); - const available = await loadModelStatus(); - const isUnavailable = available === undefined ? response.success : !available; - if (isUnavailable) { - setFaceModelAvailable(false); - showMessage('success', t('settings.modelDeinitSuccess')); - } else { - showMessage('error', response.message ?? t('settings.modelDeinitError')); - } - } catch (error) { - loggers.settings.error('Failed to deinit model:', error); - showMessage('error', formatErrorMessage('settings.modelDeinitError', error)); - } finally { - setModelLoading(false); - } - }, [formatErrorMessage, loadModelStatus, showMessage, t]); - - const hasServerChanged = (server: ConfigState['server']) => { - if (!initialServer) return false; - return ( - server.host !== initialServer.host || - server.port !== initialServer.port || - server.debug !== initialServer.debug - ); - }; - - const hasJobChanged = (job: ConfigState['job']) => { - if (!initialJob) return false; - return job.maxConcurrentJobs !== initialJob.maxConcurrentJobs; - }; - - const saveConfig = useCallback(async () => { - if (!config) return; - try { - setSaving(true); - let pendingRestart = hasPendingBackendUrl(); - const serverChanged = hasServerChanged(config.server); - const jobChanged = hasJobChanged(config.job); - const desiredUrl = buildBackendUrl(config.server.host, config.server.port) ?? ''; - const currentUrl = normalizeBackendUrl(localStorage.getItem('slidegen.backend.url') ?? ''); - if (pendingRestart && desiredUrl && desiredUrl === currentUrl) { - clearPendingBackendUrl(); - pendingRestart = false; - } - const requiresRestart = pendingRestart || serverChanged || jobChanged; - const normalizeNumber = (value: number) => (Number.isFinite(value) ? value : 0); - await backendApi.updateConfig({ - Server: { - Host: config.server.host, - Port: normalizeNumber(config.server.port), - Debug: config.server.debug, - }, - Download: { - MaxChunks: normalizeNumber(config.download.maxChunks), - LimitBytesPerSecond: normalizeNumber(config.download.limitBytesPerSecond), - SaveFolder: config.download.saveFolder, - Retry: { - Timeout: normalizeNumber(config.download.retryTimeout), - MaxRetries: normalizeNumber(config.download.maxRetries), - }, - }, - Job: { - MaxConcurrentJobs: normalizeNumber(config.job.maxConcurrentJobs), - }, - Image: { - Face: { - Confidence: normalizeNumber(config.image.face.confidence), - UnionAll: config.image.face.unionAll, - }, - Saliency: { - PaddingTop: normalizeNumber(config.image.saliency.paddingTop), - PaddingBottom: normalizeNumber(config.image.saliency.paddingBottom), - PaddingLeft: normalizeNumber(config.image.saliency.paddingLeft), - PaddingRight: normalizeNumber(config.image.saliency.paddingRight), - }, - }, - }); - if (!serverChanged) { - setInitialServer({ ...config.server }); - } - if (!jobChanged) { - setInitialJob({ ...config.job }); - } - setRestartRequired(requiresRestart); - if (serverChanged) { - storePendingBackendUrl(config.server.host, config.server.port); - } else if (!pendingRestart) { - storeBackendUrl(config.server.host, config.server.port); - } - showMessage('success', t('settings.saveSuccess')); - } catch (error) { - loggers.settings.error('Failed to save config:', error); - showMessage('error', formatErrorMessage('settings.saveError', error)); - } finally { - setSaving(false); - } - }, [ - clearPendingBackendUrl, - config, - formatErrorMessage, - hasPendingBackendUrl, - showMessage, - storeBackendUrl, - storePendingBackendUrl, - t, - ]); - - const reloadConfig = useCallback(async () => { - try { - setLoading(true); - await backendApi.reloadConfig(); - await loadConfig(); - showMessage('success', t('settings.reloadSuccess')); - } catch (error) { - loggers.settings.error('Failed to reload config:', error); - showMessage('error', formatErrorMessage('settings.reloadError', error)); - } finally { - setLoading(false); - } - }, [formatErrorMessage, loadConfig, showMessage, t]); - - const resetConfig = useCallback(async () => { - if (!window.confirm(t('settings.confirmReset'))) return; - try { - setLoading(true); - await backendApi.resetConfig(); - await loadConfig(); - showMessage('success', t('settings.resetSuccess')); - } catch (error) { - loggers.settings.error('Failed to reset config:', error); - showMessage('error', formatErrorMessage('settings.resetError', error)); - } finally { - setLoading(false); - } - }, [formatErrorMessage, loadConfig, showMessage, t]); - - const handleRestartServer = useCallback(async () => { - if (!window.electronAPI?.restartBackend) { - showMessage('warning', t('settings.restartUnavailable')); - return; - } - try { - const restarted = await window.electronAPI.restartBackend(); - if (restarted) { - setRestartRequired(false); - if (config) { - storeBackendUrl(config.server.host, config.server.port); - clearPendingBackendUrl(); - await loadConfig(); - } - showMessage('success', t('settings.restartSuccess')); - } else { - showMessage('error', t('settings.restartError')); - } - } catch (error) { - loggers.settings.error('Failed to restart server:', error); - showMessage('error', formatErrorMessage('settings.restartError', error)); - } - }, [ - clearPendingBackendUrl, - config, - formatErrorMessage, - loadConfig, - showMessage, - storeBackendUrl, - t, - ]); - - const updateServer = useCallback((patch: Partial) => { - setConfig((prev) => { - if (!prev) return prev; - return { - ...prev, - server: { ...prev.server, ...patch }, - }; - }); - }, []); - - const updateDownload = useCallback((patch: Partial) => { - setConfig((prev) => { - if (!prev) return prev; - return { - ...prev, - download: { ...prev.download, ...patch }, - }; - }); - }, []); - - const updateJob = useCallback((patch: Partial) => { - setConfig((prev) => { - if (!prev) return prev; - return { - ...prev, - job: { ...prev.job, ...patch }, - }; - }); - }, []); - - const updateFace = useCallback((patch: Partial) => { - setConfig((prev) => { - if (!prev) return prev; - return { - ...prev, - image: { - ...prev.image, - face: { ...prev.image.face, ...patch }, - }, - }; - }); - }, []); - - const updateSaliency = useCallback((patch: Partial) => { - setConfig((prev) => { - if (!prev) return prev; - return { - ...prev, - image: { - ...prev.image, - saliency: { ...prev.image.saliency, ...patch }, - }, - }; - }); - }, []); - - const handleSelectDownloadFolder = useCallback(async () => { - if (!config || !window.electronAPI) return; - const folder = await window.electronAPI.openFolder(); - if (folder) { - updateDownload({ saveFolder: folder }); - } - }, [config, updateDownload]); - - return { - // State - config, - loading, - saving, - message, - restartRequired, - showRestartNotification, - isRestartNotificationClosing, - showStatusNotification, - isStatusNotificationClosing, - faceModelAvailable, - modelLoading, - - // State setters - setRestartRequired, - setShowRestartNotification, - setIsRestartNotificationClosing, - - // Handlers - handleNumberChange, - handleNumberBlur, - handleNumberFocus, - hideStatusNotification, - - // Config operations - loadConfig, - loadModelStatus, - saveConfig, - reloadConfig, - resetConfig, - handleRestartServer, - handleInitModel, - handleDeinitModel, - - // Update functions - updateServer, - updateDownload, - updateJob, - updateFace, - updateSaliency, - handleSelectDownloadFolder, - }; -}; diff --git a/frontend/src/features/settings/index.ts b/frontend/src/features/settings/index.ts deleted file mode 100644 index 5ef8fd00..00000000 --- a/frontend/src/features/settings/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from './SettingMenu'; diff --git a/frontend/src/features/settings/types/index.ts b/frontend/src/features/settings/types/index.ts deleted file mode 100644 index a444f3a4..00000000 --- a/frontend/src/features/settings/types/index.ts +++ /dev/null @@ -1,152 +0,0 @@ -import type { Theme } from '@/shared/contexts/AppContextType'; -import type { Language } from '@/shared/locales'; - -export type SettingTab = 'appearance' | 'server' | 'download' | 'job' | 'image'; - -export interface ConfigState { - server: { - host: string; - port: number; - debug: boolean; - }; - download: { - maxChunks: number; - limitBytesPerSecond: number; - saveFolder: string; - retryTimeout: number; - maxRetries: number; - proxy: { - useProxy: boolean; - proxyAddress: string; - username: string; - password: string; - domain: string; - }; - }; - job: { - maxConcurrentJobs: number; - }; - image: { - face: { - confidence: number; - unionAll: boolean; - maxDimension: number; - }; - saliency: { - paddingTop: number; - paddingBottom: number; - paddingLeft: number; - paddingRight: number; - }; - }; -} - -export type TranslationFn = (key: string) => string; - -export type NumberChangeHandler = (value: string, apply: (next: number) => void) => void; -export type NumberBlurHandler = (value: string, apply: (next: number) => void) => void; -export type NumberFocusHandler = (event: React.FocusEvent) => void; - -export interface SettingsNotificationsProps { - showRestartNotification: boolean; - isRestartNotificationClosing: boolean; - onRestart: () => void; - message: { type: 'success' | 'error' | 'warning'; text: string } | null; - showStatusNotification: boolean; - isStatusNotificationClosing: boolean; - onCloseStatus: () => void; - showLockedNotification: boolean; - t: TranslationFn; -} - -export interface SettingsTabsProps { - activeTab: SettingTab; - onSelectTab: (tab: SettingTab) => void; - t: TranslationFn; -} - -export interface AppearanceTabProps { - theme: Theme; - language: Language; - enableAnimations: boolean; - closeToTray: boolean; - setTheme: (value: Theme) => void; - setLanguage: (value: Language) => void; - setEnableAnimations: (value: boolean) => void; - setCloseToTray: (value: boolean) => void; - t: TranslationFn; -} - -export interface ServerTabProps { - loading: boolean; - config: ConfigState | null; - canEditConfig: boolean; - isLocked: boolean; - updateServer: (patch: Partial) => void; - handleNumberChange: NumberChangeHandler; - handleNumberBlur: NumberBlurHandler; - handleNumberFocus: NumberFocusHandler; - t: TranslationFn; -} - -export interface DownloadTabProps { - loading: boolean; - config: ConfigState | null; - canEditConfig: boolean; - isLocked: boolean; - updateDownload: (patch: Partial) => void; - handleNumberChange: NumberChangeHandler; - handleNumberBlur: NumberBlurHandler; - handleNumberFocus: NumberFocusHandler; - onSelectFolder: () => Promise; - t: TranslationFn; -} - -export interface JobTabProps { - loading: boolean; - config: ConfigState | null; - canEditConfig: boolean; - isLocked: boolean; - updateJob: (patch: Partial) => void; - handleNumberChange: NumberChangeHandler; - handleNumberBlur: NumberBlurHandler; - handleNumberFocus: NumberFocusHandler; - t: TranslationFn; -} - -export interface ImageTabProps { - loading: boolean; - config: ConfigState | null; - canEditConfig: boolean; - isLocked: boolean; - faceModelAvailable: boolean; - modelLoading: boolean; - onInitModel: () => void; - onDeinitModel: () => void; - updateFace: (patch: Partial) => void; - updateSaliency: (patch: Partial) => void; - createPadStyles: (padding: { - paddingTop: number; - paddingBottom: number; - paddingLeft: number; - paddingRight: number; - }) => { - base: { inset: string }; - detect: { inset: string }; - crop: { top: string; right: string; bottom: string; left: string }; - }; - handleNumberChange: NumberChangeHandler; - handleNumberBlur: NumberBlurHandler; - handleNumberFocus: NumberFocusHandler; - t: TranslationFn; -} - -export interface SettingActionsProps { - saving: boolean; - isEditable: boolean; - showActions: boolean; - onSave: () => void; - onReload: () => void; - onReset: () => void; - t: TranslationFn; -} diff --git a/frontend/src/features/settings/utils/index.ts b/frontend/src/features/settings/utils/index.ts deleted file mode 100644 index 41987be8..00000000 --- a/frontend/src/features/settings/utils/index.ts +++ /dev/null @@ -1,114 +0,0 @@ -import type { ConfigGetSuccess } from '@/shared/services/backend/config/types'; -import type { ConfigState } from '../types'; - -export const getErrorDetail = (error: unknown): string => { - if (error instanceof Error && error.message) return error.message; - if (typeof error === 'string') return error; - if (error && typeof error === 'object' && 'message' in error) { - const value = (error as { message?: string }).message; - if (value) return value; - } - return ''; -}; - -export const parseConfigResponse = (data: ConfigGetSuccess) => { - const config: ConfigState = { - server: { - host: data.server.host ?? '', - port: data.server.port ?? 0, - debug: data.server.debug ?? false, - }, - download: { - maxChunks: data.download.maxChunks ?? 0, - limitBytesPerSecond: data.download.limitBytesPerSecond ?? 0, - saveFolder: data.download.saveFolder ?? '', - retryTimeout: data.download.retry.timeout ?? 0, - maxRetries: data.download.retry.maxRetries ?? 0, - proxy: { - useProxy: data.download.proxy?.useProxy ?? false, - proxyAddress: data.download.proxy?.proxyAddress ?? '', - username: data.download.proxy?.username ?? '', - password: data.download.proxy?.password ?? '', - domain: data.download.proxy?.domain ?? '', - }, - }, - job: { - maxConcurrentJobs: data.job.maxConcurrentJobs ?? 0, - }, - image: { - face: { - confidence: data.image.face.confidence ?? 0, - unionAll: data.image.face.unionAll ?? false, - maxDimension: data.image.face.maxDimension ?? 1280, - }, - saliency: { - paddingTop: data.image.saliency.paddingTop ?? 0, - paddingBottom: data.image.saliency.paddingBottom ?? 0, - paddingLeft: data.image.saliency.paddingLeft ?? 0, - paddingRight: data.image.saliency.paddingRight ?? 0, - }, - }, - }; - - return { - config, - server: config.server, - }; -}; - -export const splitNotificationText = (text: string) => { - const idx = text.indexOf(':'); - if (idx <= 0 || idx === text.length - 1) { - return { title: text.trim(), detail: '' }; - } - return { - title: text.slice(0, idx).trim(), - detail: text.slice(idx + 1).trim(), - }; -}; - -export const buildBackendUrl = (host: string, port: number): string | undefined => { - if (!host || !port) return; - const trimmedHost = host.trim(); - if (!trimmedHost) return; - - const hasScheme = /^https?:\/\//i.test(trimmedHost); - const base = hasScheme ? trimmedHost : `http://${trimmedHost}`; - const normalizedHost = base.replace(/^(https?:\/\/)localhost(?=[:/]|$)/i, '$1127.0.0.1'); - const normalizedBase = normalizedHost.endsWith('/') - ? normalizedHost.slice(0, -1) - : normalizedHost; - const hasPort = /:\d+$/.test(normalizedBase); - return hasPort ? normalizedBase : `${normalizedBase}:${port}`; -}; - -export const normalizeBackendUrl = (url: string): string => { - const trimmed = url.trim(); - if (!trimmed) return ''; - const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`; - const normalizedHost = withScheme.replace(/^(https?:\/\/)localhost(?=[:/]|$)/i, '$1127.0.0.1'); - return normalizedHost.endsWith('/') ? normalizedHost.slice(0, -1) : normalizedHost; -}; - -export const createPadStyles = (padding: { - paddingTop: number; - paddingBottom: number; - paddingLeft: number; - paddingRight: number; -}) => { - const baseInset = 10; - const detectInset = 25; - const range = detectInset - baseInset; - const clamp01 = (value: number) => Math.min(1, Math.max(0, Number.isFinite(value) ? value : 0)); - const resolveInset = (value: number) => `${detectInset - range * clamp01(value)}%`; - return { - base: { inset: `${baseInset}%` }, - detect: { inset: `${detectInset}%` }, - crop: { - top: resolveInset(padding.paddingTop), - right: resolveInset(padding.paddingRight), - bottom: resolveInset(padding.paddingBottom), - left: resolveInset(padding.paddingLeft), - }, - }; -}; diff --git a/frontend/src/global.d.ts b/frontend/src/global.d.ts deleted file mode 100644 index 8ded704a..00000000 --- a/frontend/src/global.d.ts +++ /dev/null @@ -1,46 +0,0 @@ -export {}; - -declare global { - const __APP_VERSION__: string; - - interface UpdateState { - status: string; - info?: { version: string; releaseNotes?: string }; - progress?: number; - error?: string; - } - - interface Window { - electronAPI: { - openFile: (filters?: { name: string; extensions: string[] }[]) => Promise; - openMultipleFiles: ( - filters?: { name: string; extensions: string[] }[], - ) => Promise; - openFolder: () => Promise; - saveFile: (filters?: { name: string; extensions: string[] }[]) => Promise; - openUrl: (url: string) => Promise; - openPath: (path: string) => Promise; - readSettings: (filename: string) => Promise; - writeSettings: (filename: string, data: string) => Promise; - windowControl: (action: 'minimize' | 'maximize' | 'close') => Promise; - hideToTray: () => Promise; - setProgressBar: (value: number) => Promise; - restartBackend: () => Promise; - logRenderer: ( - level: 'debug' | 'info' | 'warn' | 'error', - message: string, - source?: string, - ) => void; - onNavigate: ( - handler: (menu: 'input' | 'process' | 'download' | 'setting' | 'about') => void, - ) => () => void; - setTrayLocale: (locale: 'vi' | 'en') => Promise; - checkForUpdates: () => Promise; - downloadUpdate: () => Promise; - installUpdate: () => void; - onUpdateStatus: (handler: (state: UpdateState) => void) => () => void; - isPortable: () => Promise; - }; - getAssetPath: (...p: string[]) => string; - } -} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx deleted file mode 100644 index 1f20bce5..00000000 --- a/frontend/src/main.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import React from 'react'; -import ReactDOM from 'react-dom/client'; -import App from '@/app/App'; -import AppProviders from '@/app/providers/AppProviders'; -import '@/shared/styles/theme.css'; -import '@/shared/styles/index.css'; - -const formatLogArg = (arg: unknown): string => { - if (arg instanceof Error) { - return arg.stack || `${arg.name}: ${arg.message}`; - } - if (typeof arg === 'string') return arg; - if (typeof arg === 'number' || typeof arg === 'boolean' || typeof arg === 'bigint') { - return String(arg); - } - if (arg === null || arg === undefined) return String(arg); - try { - return JSON.stringify(arg); - } catch { - return String(arg); - } -}; - -const initRendererLogging = () => { - if (!window.electronAPI?.logRenderer) return; - const flagKey = '__rendererLoggerInstalled'; - const windowFlags = window as unknown as Record; - if (windowFlags[flagKey]) return; - windowFlags[flagKey] = true; - - const originalConsole = { - log: console.log.bind(console), - info: console.info.bind(console), - warn: console.warn.bind(console), - error: console.error.bind(console), - debug: console.debug.bind(console), - }; - - const sendRendererLog = (level: 'debug' | 'info' | 'warn' | 'error', args: unknown[]) => { - const message = args.map(formatLogArg).join(' '); - window.electronAPI.logRenderer(level, message); - }; - - console.log = (...args: unknown[]) => { - originalConsole.log(...args); - sendRendererLog('info', args); - }; - console.info = (...args: unknown[]) => { - originalConsole.info(...args); - sendRendererLog('info', args); - }; - console.warn = (...args: unknown[]) => { - originalConsole.warn(...args); - sendRendererLog('warn', args); - }; - console.error = (...args: unknown[]) => { - originalConsole.error(...args); - sendRendererLog('error', args); - }; - console.debug = (...args: unknown[]) => { - originalConsole.debug(...args); - sendRendererLog('debug', args); - }; - - window.addEventListener( - 'error', - (event: Event) => { - if (event instanceof ErrorEvent) { - sendRendererLog('error', [ - 'Uncaught error:', - event.message, - event.filename, - event.lineno, - event.colno, - event.error, - ]); - return; - } - const target = event.target as { src?: string; href?: string } | null; - const resourceUrl = target?.src ?? target?.href ?? ''; - if (resourceUrl) { - sendRendererLog('error', ['Resource load error:', resourceUrl]); - } else { - sendRendererLog('error', ['Resource load error: unknown target']); - } - }, - true, - ); - - window.addEventListener('unhandledrejection', (event) => { - sendRendererLog('error', ['Unhandled rejection:', event.reason]); - }); - - sendRendererLog('info', ['Renderer logger initialized']); -}; - -initRendererLogging(); - -ReactDOM.createRoot(document.getElementById('root')!).render( - - - - - , -); diff --git a/frontend/src/shared/components/ShapeSelector.css b/frontend/src/shared/components/ShapeSelector.css deleted file mode 100644 index edec9f5b..00000000 --- a/frontend/src/shared/components/ShapeSelector.css +++ /dev/null @@ -1,130 +0,0 @@ -.shape-selector { - position: relative; - width: 100%; - z-index: 1; -} - -.shape-selector.is-active { - z-index: 100; -} - -.shape-selector-trigger { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--spacing-sm); - padding: 8px 12px; - min-height: 42px; - width: 100%; - background-color: var(--input-bg); - border: 1px solid var(--input-border); - border-radius: var(--radius-md); - cursor: pointer; - transition: - border-color var(--transition-fast), - box-shadow var(--transition-fast); -} - -.shape-selector-trigger:hover { - border-color: var(--accent-primary); -} - -.shape-option-content { - display: flex; - align-items: center; - gap: var(--spacing-sm); - flex: 1; -} - -.shape-preview-small { - width: 22px; - height: 22px; - object-fit: contain; - border-radius: 4px; - background: var(--bg-secondary); - border: 1px solid var(--border-primary); - padding: 2px; -} - -.shape-placeholder { - color: var(--input-placeholder); - flex: 1; -} - -.shape-name { - color: var(--input-text); - font-size: var(--font-sm); - font-weight: var(--font-semibold); - flex: 1; -} - -.shape-id { - color: var(--text-tertiary); - font-size: var(--font-xs); -} - -.dropdown-arrow { - color: var(--text-tertiary); - font-size: 11px; -} - -.shape-dropdown { - position: absolute; - top: calc(100% + 6px); - left: 0; - right: 0; - max-height: 280px; - overflow-y: auto; - background-color: var(--bg-tertiary); - border: 1px solid var(--border-primary); - border-radius: var(--radius-md); - box-shadow: var(--shadow-md); - z-index: 2000; -} - -.shape-option { - display: flex; - align-items: center; - gap: var(--spacing-md); - padding: 10px 12px; - cursor: pointer; - transition: background-color var(--transition-fast); -} - -.shape-option:hover { - background-color: var(--bg-hover); -} - -.shape-option.selected { - background-color: var(--accent-soft); -} - -.shape-preview { - width: 40px; - height: 40px; - object-fit: contain; - border: 1px solid var(--border-primary); - border-radius: 8px; - background-color: var(--bg-secondary); - padding: 4px; -} - -.shape-option-empty { - padding: 12px; - text-align: center; - color: var(--text-tertiary); - font-size: var(--font-sm); -} - -.shape-dropdown::-webkit-scrollbar { - width: 8px; -} - -.shape-dropdown::-webkit-scrollbar-track { - background: var(--scrollbar-track); -} - -.shape-dropdown::-webkit-scrollbar-thumb { - background: var(--scrollbar-thumb); - border-radius: 8px; -} diff --git a/frontend/src/shared/components/ShapeSelector.tsx b/frontend/src/shared/components/ShapeSelector.tsx deleted file mode 100644 index 0ccf11e6..00000000 --- a/frontend/src/shared/components/ShapeSelector.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import React, { memo, useCallback, useMemo, useState, useRef, useEffect } from 'react'; -import './ShapeSelector.css'; - -/** Shape data for selector options. */ -export interface Shape { - /** Unique shape identifier. */ - id: string; - /** Display name. */ - name: string; - /** Preview image URL or data URI. */ - preview: string; -} - -/** Props for {@link ShapeSelector}. */ -interface ShapeSelectorProps { - /** Available shapes to select from. */ - shapes: Shape[]; - /** Currently selected shape ID. */ - value: string; - /** Callback when selection changes. */ - onChange: (shapeId: string) => void; - /** Placeholder text when no selection. */ - placeholder?: string; -} - -/** - * Dropdown selector for PowerPoint shapes with image previews. - * - * @remarks - * Displays shape thumbnail, name, and ID in both trigger and dropdown. - * Closes automatically when clicking outside. - */ -const ShapeSelector: React.FC = memo( - ({ shapes, value, onChange, placeholder = 'Chọn shape...' }) => { - const [isOpen, setIsOpen] = useState(false); - const dropdownRef = useRef(null); - - const selectedShape = useMemo(() => shapes.find((s) => s.id === value), [shapes, value]); - - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { - setIsOpen(false); - } - }; - - if (isOpen) { - document.addEventListener('mousedown', handleClickOutside); - } - - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, [isOpen]); - - const handleSelect = useCallback( - (shapeId: string) => { - onChange(shapeId); - setIsOpen(false); - }, - [onChange], - ); - - const toggleOpen = useCallback(() => { - setIsOpen((prev) => !prev); - }, []); - - return ( -
-
- {selectedShape ? ( -
- {selectedShape.name} - {selectedShape.name} - {selectedShape.id} -
- ) : ( - {placeholder} - )} - -
- - {isOpen && ( -
- {shapes.length === 0 ? ( -
Không có shape nào
- ) : ( - shapes.map((shape) => ( -
handleSelect(shape.id)} - > - {shape.name} - {shape.name} - {shape.id} -
- )) - )} -
- )} -
- ); - }, -); - -ShapeSelector.displayName = 'ShapeSelector'; - -export default ShapeSelector; diff --git a/frontend/src/shared/components/Sidebar.css b/frontend/src/shared/components/Sidebar.css deleted file mode 100644 index e0f6049c..00000000 --- a/frontend/src/shared/components/Sidebar.css +++ /dev/null @@ -1,180 +0,0 @@ -.sidebar { - display: flex; - flex-direction: column; - height: 100%; - background: - linear-gradient(165deg, rgba(255, 255, 255, 0.06), transparent 70%), var(--sidebar-bg); - border: 1px solid var(--sidebar-border); - border-radius: var(--radius-xl); - box-shadow: var(--shadow-md); - overflow: hidden; -} - -.sidebar-header { - display: flex; - align-items: center; - gap: var(--spacing-md); - padding: var(--spacing-2xl); - border-bottom: 1px solid var(--sidebar-border); -} - -.sidebar-logo { - width: 32px; - height: 32px; - object-fit: contain; - filter: drop-shadow(0 6px 14px rgba(0, 0, 0, 0.15)); -} - -.sidebar-header h2 { - font-size: var(--font-lg); - color: var(--sidebar-text); - font-weight: var(--font-bold); - letter-spacing: 0.2px; -} - -.sidebar-menu { - list-style: none; - padding: var(--spacing-lg); - display: flex; - flex-direction: column; - gap: var(--spacing-sm); - flex: 1; -} - -.sidebar-item { - display: flex; - align-items: center; - gap: var(--spacing-md); - padding: 12px 16px; - border-radius: var(--radius-md); - cursor: pointer; - color: var(--sidebar-text); - font-weight: var(--font-semibold); - transition: - background-color var(--transition-fast), - transform var(--transition-fast), - color var(--transition-fast); -} - -.sidebar-item:hover { - background-color: var(--sidebar-item-hover); - transform: translateX(2px); -} - -.sidebar-item.active { - background: linear-gradient(135deg, rgba(15, 118, 110, 0.18), rgba(15, 118, 110, 0.06)); - color: var(--sidebar-text-active); - border: 1px solid rgba(15, 118, 110, 0.2); -} - -[data-theme='dark'] .sidebar-item.active { - background: linear-gradient(135deg, rgba(45, 212, 191, 0.18), rgba(45, 212, 191, 0.06)); - border-color: rgba(45, 212, 191, 0.3); -} - -.sidebar-icon { - width: 20px; - height: 20px; - object-fit: contain; - filter: brightness(0.7); -} - -[data-theme='light'] .sidebar-icon { - filter: brightness(0.6); -} - -.sidebar-item.active .sidebar-icon { - filter: none; -} - -.sidebar-label { - font-size: var(--font-base); -} - -.sidebar-footer { - display: flex; - align-items: center; - gap: var(--spacing-md); - padding: var(--spacing-lg); - border-top: 1px solid var(--sidebar-border); -} - -.footer-spacer { - flex: 1; -} - -.sidebar-icon-btn { - width: 40px; - height: 40px; - border-radius: var(--radius-md); - border: 1px solid transparent; - background: transparent; - display: grid; - place-items: center; - cursor: pointer; - transition: - background-color var(--transition-fast), - border-color var(--transition-fast), - transform var(--transition-fast); -} - -.sidebar-icon-btn:hover { - background-color: var(--sidebar-item-hover); - border-color: var(--sidebar-border); - transform: translateY(-1px); -} - -.sidebar-icon-btn.active { - background-color: var(--sidebar-item-active); - border-color: rgba(15, 118, 110, 0.25); -} - -[data-theme='dark'] .sidebar-icon-btn.active { - border-color: rgba(45, 212, 191, 0.35); -} - -.footer-icon { - width: 18px; - height: 18px; - object-fit: contain; - filter: brightness(0.7); -} - -[data-theme='light'] .footer-icon { - filter: brightness(0.7); -} - -.sidebar-icon-btn.active .footer-icon { - filter: none; -} - -@media (max-width: 980px) { - .sidebar { - flex-direction: row; - align-items: center; - padding: var(--spacing-md); - height: auto; - width: 100%; - } - - .sidebar-header { - padding: var(--spacing-md); - border-bottom: none; - } - - .sidebar-menu { - flex-direction: row; - padding: var(--spacing-md); - gap: var(--spacing-sm); - overflow-x: auto; - } - - .sidebar-item { - white-space: nowrap; - } - - .sidebar-footer { - padding: var(--spacing-md); - border-top: none; - } -} diff --git a/frontend/src/shared/components/Sidebar.tsx b/frontend/src/shared/components/Sidebar.tsx deleted file mode 100644 index 2e1b407b..00000000 --- a/frontend/src/shared/components/Sidebar.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import React, { memo, useMemo } from 'react'; -import { useApp } from '@/shared/contexts/useApp'; -import { getAssetPath } from '@/shared/utils/paths'; -import './Sidebar.css'; - -/** Available menu identifiers for navigation. */ -export type MenuType = 'input' | 'setting' | 'download' | 'process' | 'about'; - -/** Props for {@link Sidebar}. */ -interface SidebarProps { - /** Currently active menu. */ - currentMenu: MenuType; - /** Callback when user selects a different menu. */ - onMenuChange: (menu: MenuType) => void; -} - -/** - * Main navigation sidebar with menu items and footer buttons. - * - * @remarks - * Displays app logo, main menu items (Create Task, Process, Results), - * and footer buttons (Settings, About). - */ -const Sidebar: React.FC = memo(({ currentMenu, onMenuChange }) => { - const { t } = useApp(); - - const menuItems = useMemo( - () => [ - { - id: 'input' as MenuType, - label: t('sideBar.createTask'), - icon: getAssetPath('images', 'createTask.png'), - activeIcon: getAssetPath('images', 'createTask-selected.png'), - }, - { - id: 'process' as MenuType, - label: t('process.title'), - icon: getAssetPath('images', 'process.png'), - activeIcon: getAssetPath('images', 'process-selected.png'), - }, - { - id: 'download' as MenuType, - label: t('results.title'), - icon: getAssetPath('images', 'result.png'), - activeIcon: getAssetPath('images', 'result-selected.png'), - }, - ], - [t], - ); - - return ( -
-
- UET Logo -

{t('app.title')}

-
-
    - {menuItems.map((item) => ( -
  • onMenuChange(item.id)} - > - {item.label} - {item.label} -
  • - ))} -
-
- -
- -
-
- ); -}); - -Sidebar.displayName = 'Sidebar'; - -export default Sidebar; diff --git a/frontend/src/shared/components/TagInput.css b/frontend/src/shared/components/TagInput.css deleted file mode 100644 index 32d5423e..00000000 --- a/frontend/src/shared/components/TagInput.css +++ /dev/null @@ -1,123 +0,0 @@ -.tag-input-container { - position: relative; - width: 100%; - z-index: 1; -} - -.tag-input-container:focus-within, -.tag-input-container.is-active { - z-index: 1000; -} - -.tag-input-wrapper { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--spacing-sm); - padding: 8px 12px; - min-height: 42px; - width: 100%; - background-color: var(--input-bg); - border: 1px solid var(--input-border); - border-radius: var(--radius-md); - transition: - border-color var(--transition-fast), - box-shadow var(--transition-fast); - cursor: text; -} - -.tag-input-wrapper:focus-within { - border-color: var(--accent-primary); - box-shadow: var(--focus-ring); -} - -.tag-item { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 4px 10px; - background: var(--accent-soft); - color: var(--text-primary); - border-radius: var(--radius-full); - font-size: var(--font-sm); - font-weight: var(--font-semibold); - max-width: 180px; -} - -.tag-text { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.tag-remove { - border: none; - background: transparent; - color: var(--text-secondary); - cursor: pointer; - font-size: 14px; -} - -.tag-remove:hover { - color: var(--text-primary); -} - -.tag-input-field { - flex: 1 1 auto; - min-width: 120px; - border: none !important; - background: transparent !important; - color: var(--input-text); - font-size: var(--font-base); - font-family: inherit; - outline: none; - padding: 4px 0 !important; -} - -.tag-input-field::placeholder { - color: var(--input-placeholder); -} - -.tag-suggestions { - position: absolute; - top: calc(100% + 6px); - left: 0; - right: 0; - max-height: 260px; - overflow-y: auto; - background: var(--bg-tertiary); - border: 1px solid var(--border-primary); - border-radius: var(--radius-md); - box-shadow: var(--shadow-md); - z-index: 9999; - animation: fadeInUp var(--transition-fast); -} - -.tag-suggestion-item { - padding: 10px 12px; - cursor: pointer; - font-size: var(--font-base); - color: var(--text-primary); - transition: - background-color var(--transition-fast), - color var(--transition-fast); -} - -.tag-suggestion-item:hover, -.tag-suggestion-item.selected { - background-color: var(--bg-hover); - color: var(--accent-primary); -} - -.tag-suggestions::-webkit-scrollbar { - width: 8px; -} - -.tag-suggestions::-webkit-scrollbar-track { - background: var(--scrollbar-track); -} - -.tag-suggestions::-webkit-scrollbar-thumb { - background: var(--scrollbar-thumb); - border-radius: 8px; -} diff --git a/frontend/src/shared/components/TagInput.tsx b/frontend/src/shared/components/TagInput.tsx deleted file mode 100644 index 69bb5bc0..00000000 --- a/frontend/src/shared/components/TagInput.tsx +++ /dev/null @@ -1,180 +0,0 @@ -import React, { memo, useCallback, useMemo, useRef, useState } from 'react'; -import './TagInput.css'; - -/** Props for {@link TagInput}. */ -interface TagInputProps { - /** Current selected tags. */ - value: string[]; - /** Callback when tags change. */ - onChange: (tags: string[]) => void; - /** Available suggestions for autocomplete. */ - suggestions: string[]; - /** Placeholder text when empty. */ - placeholder?: string; -} - -/** - * Multi-select tag input with autocomplete suggestions. - * - * @remarks - * Supports keyboard navigation (Arrow keys, Enter, Escape, Backspace). - * Tags can be added by selecting from suggestions or typing with comma. - */ -const TagInput: React.FC = memo(({ value, onChange, suggestions, placeholder }) => { - const [inputValue, setInputValue] = useState(''); - const [showSuggestions, setShowSuggestions] = useState(false); - const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(-1); - const inputRef = useRef(null); - const dropdownRef = useRef(null); - - const filteredSuggestions = useMemo(() => { - const filtered = suggestions.filter((suggestion) => !value.includes(suggestion)); - if (!inputValue) { - return filtered; - } - return filtered.filter((suggestion) => suggestion.includes(inputValue)); - }, [inputValue, suggestions, value]); - - const addTag = useCallback( - (tag: string) => { - const suggestionExists = suggestions.some((s) => s === tag); - if (tag && !value.includes(tag) && suggestionExists) { - onChange([...value, tag]); - setInputValue(''); - setShowSuggestions(true); - setSelectedSuggestionIndex(-1); - } - }, - [onChange, suggestions, value], - ); - - const removeTag = useCallback( - (tagToRemove: string) => { - onChange(value.filter((tag) => tag !== tagToRemove)); - }, - [onChange, value], - ); - - const handleInputChange = useCallback( - (e: React.ChangeEvent) => { - const newValue = e.target.value; - - if (newValue.endsWith(',')) { - const tag = newValue.slice(0, -1); - if (tag) { - addTag(tag); - } - return; - } - - setInputValue(newValue); - setSelectedSuggestionIndex(-1); - setShowSuggestions(true); - }, - [addTag], - ); - - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - e.preventDefault(); - if (selectedSuggestionIndex >= 0 && filteredSuggestions[selectedSuggestionIndex]) { - addTag(filteredSuggestions[selectedSuggestionIndex]); - } else if (inputValue) { - addTag(inputValue); - } - } else if (e.key === 'Backspace' && !inputValue && value.length > 0) { - removeTag(value[value.length - 1]); - } else if (e.key === 'ArrowDown') { - e.preventDefault(); - if (showSuggestions && filteredSuggestions.length > 0) { - setSelectedSuggestionIndex((prev) => - prev < filteredSuggestions.length - 1 ? prev + 1 : prev, - ); - } - } else if (e.key === 'ArrowUp') { - e.preventDefault(); - if (showSuggestions && filteredSuggestions.length > 0) { - setSelectedSuggestionIndex((prev) => (prev > 0 ? prev - 1 : -1)); - } - } else if (e.key === 'Escape') { - setShowSuggestions(false); - setSelectedSuggestionIndex(-1); - } - }, - [ - addTag, - filteredSuggestions, - inputValue, - removeTag, - selectedSuggestionIndex, - showSuggestions, - value, - ], - ); - - const handleInputFocus = useCallback(() => { - setShowSuggestions(true); - }, []); - - const handleInputBlur = useCallback(() => { - setTimeout(() => { - setShowSuggestions(false); - setSelectedSuggestionIndex(-1); - }, 200); - }, []); - - return ( -
0 ? 'is-active' : ''}`} - > -
inputRef.current?.focus()}> - {value.map((tag, index) => ( -
- {tag} - -
- ))} - -
- - {showSuggestions && filteredSuggestions.length > 0 && ( -
- {filteredSuggestions.map((suggestion, index) => ( -
{ - e.preventDefault(); - addTag(suggestion); - }} - > - {suggestion} -
- ))} -
- )} -
- ); -}); - -TagInput.displayName = 'TagInput'; - -export default TagInput; diff --git a/frontend/src/shared/components/TitleBar.css b/frontend/src/shared/components/TitleBar.css deleted file mode 100644 index 24b1f758..00000000 --- a/frontend/src/shared/components/TitleBar.css +++ /dev/null @@ -1,80 +0,0 @@ -.title-bar { - height: 44px; - display: flex; - align-items: center; - justify-content: space-between; - padding: 0 8px 0 var(--spacing-lg); - background: var(--bg-secondary); - border-bottom: 1px solid var(--border-primary); - -webkit-app-region: drag; -} - -.title-bar-left { - display: flex; - align-items: center; - gap: var(--spacing-sm); - color: var(--text-primary); - font-weight: var(--font-semibold); -} - -.title-bar-icon { - width: 18px; - height: 18px; - object-fit: contain; -} - -.title-bar-title { - font-size: var(--font-sm); - letter-spacing: 0.02em; -} - -.title-bar-controls { - display: flex; - align-items: center; - gap: 4px; - -webkit-app-region: no-drag; -} - -.title-bar-btn { - width: 42px; - height: 34px; - border-radius: 6px; - border: none; - background: transparent; - padding: 0; - cursor: pointer; - display: grid; - place-items: center; - transition: background-color var(--transition-fast); -} - -.title-bar-btn:hover { - background-color: rgba(148, 163, 184, 0.2); -} - -.title-bar-btn-danger { - color: #ef4444; -} - -.title-bar-btn-danger:hover { - background-color: rgba(239, 68, 68, 0.2); -} - -.title-bar-btn img { - width: 14px; - height: 14px; - object-fit: contain; - filter: brightness(0) invert(1); - transition: - filter var(--transition-fast), - opacity var(--transition-fast); - opacity: 0.8; -} - -.title-bar-btn:hover img { - opacity: 1; -} - -[data-theme='light'] .title-bar-btn img { - filter: none; -} diff --git a/frontend/src/shared/components/TitleBar.tsx b/frontend/src/shared/components/TitleBar.tsx deleted file mode 100644 index 5b2bf2f5..00000000 --- a/frontend/src/shared/components/TitleBar.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import React, { memo, useCallback } from 'react'; -import { useApp } from '@/shared/contexts/useApp'; -import { getAssetPath } from '@/shared/utils/paths'; -import './TitleBar.css'; - -/** Props for {@link TitleBar}. */ -interface TitleBarProps { - /** Window title text. */ - title: string; -} - -/** - * Custom window title bar with minimize, maximize, and close buttons. - * - * @remarks - * Supports close-to-tray behavior based on app settings. - * Replaces the native Electron title bar for a custom look. - */ -const TitleBar: React.FC = memo(({ title }) => { - const { closeToTray, t } = useApp(); - - const handleMinimize = useCallback(() => { - window.electronAPI?.windowControl('minimize'); - }, []); - - const handleMaximize = useCallback(() => { - window.electronAPI?.windowControl('maximize'); - }, []); - - const handleClose = useCallback(() => { - if (closeToTray) { - window.electronAPI?.hideToTray(); - return; - } - window.electronAPI?.windowControl('close'); - }, [closeToTray]); - - return ( -
-
- {t('app.title')} - {title} -
-
- - - -
-
- ); -}); - -TitleBar.displayName = 'TitleBar'; - -export default TitleBar; diff --git a/frontend/src/shared/contexts/AppContext.tsx b/frontend/src/shared/contexts/AppContext.tsx deleted file mode 100644 index 7c72f457..00000000 --- a/frontend/src/shared/contexts/AppContext.tsx +++ /dev/null @@ -1,144 +0,0 @@ -import React, { useState, useEffect, ReactNode, useMemo, useCallback } from 'react'; -import { translations, Language } from '@/shared/locales'; -import { AppContext, type Theme, type Settings } from './AppContextType'; - -const SETTINGS_FILE = 'app-settings.json'; - -const loadSettings = async (): Promise => { - try { - const data = await window.electronAPI.readSettings(SETTINGS_FILE); - return data ? JSON.parse(data) : null; - } catch (error) { - console.error('Failed to load settings:', error); - return null; - } -}; - -const saveSettings = async (settings: Settings) => { - try { - await window.electronAPI.writeSettings(SETTINGS_FILE, JSON.stringify(settings, null, 2)); - } catch (error) { - console.error('Failed to save settings:', error); - } -}; - -export const AppProvider: React.FC<{ children: ReactNode }> = ({ children }) => { - const [theme, setThemeState] = useState('system'); - const [resolvedTheme, setResolvedTheme] = useState<'dark' | 'light'>('dark'); - const [language, setLanguageState] = useState('vi'); - const [enableAnimations, setEnableAnimationsState] = useState(true); - const [closeToTray, setCloseToTrayState] = useState(false); - const [isLoaded, setIsLoaded] = useState(false); - - useEffect(() => { - const initSettings = async () => { - const savedSettings = await loadSettings(); - if (savedSettings) { - const savedTheme = (savedSettings.theme as Theme) || 'system'; - setThemeState(savedTheme); - setLanguageState(savedSettings.language || 'vi'); - setEnableAnimationsState(savedSettings.enableAnimations !== false); - setCloseToTrayState(savedSettings.closeToTray === true); - } - setIsLoaded(true); - }; - initSettings(); - }, []); - - useEffect(() => { - if (isLoaded) { - const settings: Settings = { - theme, - language, - enableAnimations, - closeToTray, - }; - saveSettings(settings); - } - }, [theme, language, enableAnimations, closeToTray, isLoaded]); - - useEffect(() => { - if (!isLoaded) return; - window.electronAPI?.setTrayLocale?.(language); - }, [language, isLoaded]); - - useEffect(() => { - if (typeof window === 'undefined' || !window.matchMedia) { - setResolvedTheme(theme === 'system' ? 'dark' : theme); - return; - } - - const media = window.matchMedia('(prefers-color-scheme: dark)'); - const updateResolved = () => { - if (theme === 'system') { - setResolvedTheme(media.matches ? 'dark' : 'light'); - } else { - setResolvedTheme(theme); - } - }; - - updateResolved(); - media.addEventListener('change', updateResolved); - return () => media.removeEventListener('change', updateResolved); - }, [theme]); - - useEffect(() => { - document.documentElement.setAttribute('data-theme', resolvedTheme); - if (enableAnimations) { - document.documentElement.classList.remove('no-animations'); - } else { - document.documentElement.classList.add('no-animations'); - } - }, [resolvedTheme, enableAnimations]); - - const setTheme = useCallback((newTheme: Theme) => { - setThemeState(newTheme); - }, []); - - const setLanguage = useCallback((newLanguage: Language) => { - setLanguageState(newLanguage); - }, []); - - const setEnableAnimations = useCallback((enable: boolean) => { - setEnableAnimationsState(enable); - }, []); - - const setCloseToTray = useCallback((enable: boolean) => { - setCloseToTrayState(enable); - }, []); - - const t = useCallback( - (key: string): string => { - const langTranslations = translations[language] as Record; - return langTranslations[key] || key; - }, - [language], - ); - - const contextValue = useMemo( - () => ({ - theme, - language, - enableAnimations, - closeToTray, - setTheme, - setLanguage, - setEnableAnimations, - setCloseToTray, - t, - }), - [ - theme, - language, - enableAnimations, - closeToTray, - setTheme, - setLanguage, - setEnableAnimations, - setCloseToTray, - t, - ], - ); - - return {children}; -}; diff --git a/frontend/src/shared/contexts/AppContextType.ts b/frontend/src/shared/contexts/AppContextType.ts deleted file mode 100644 index c430da3f..00000000 --- a/frontend/src/shared/contexts/AppContextType.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { createContext } from 'react'; -import type { Language } from '@/shared/locales'; - -/** Available theme options. */ -export type Theme = 'dark' | 'light' | 'system'; - -/** Persisted application settings. */ -export interface Settings { - theme: Theme; - language: Language; - enableAnimations: boolean; - closeToTray: boolean; -} - -/** App context value with settings and translation function. */ -export interface AppContextType { - /** Current theme setting. */ - theme: Theme; - /** Current language. */ - language: Language; - /** Whether animations are enabled. */ - enableAnimations: boolean; - /** Whether to minimize to tray on close. */ - closeToTray: boolean; - /** Update theme setting. */ - setTheme: (theme: Theme) => void; - /** Update language setting. */ - setLanguage: (language: Language) => void; - /** Toggle animations. */ - setEnableAnimations: (enable: boolean) => void; - /** Toggle close-to-tray behavior. */ - setCloseToTray: (enable: boolean) => void; - /** Translate a key to current language. */ - t: (key: string) => string; -} - -export const AppContext = createContext(undefined); diff --git a/frontend/src/shared/contexts/JobContext.tsx b/frontend/src/shared/contexts/JobContext.tsx deleted file mode 100644 index 9cfe08a7..00000000 --- a/frontend/src/shared/contexts/JobContext.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import React, { ReactNode } from 'react'; -import { JobContext } from './JobContextType'; -import { useJobProvider } from './hooks'; - -export const JobProvider: React.FC<{ children: ReactNode }> = ({ children }) => { - const value = useJobProvider(); - - return {children}; -}; diff --git a/frontend/src/shared/contexts/JobContextType.ts b/frontend/src/shared/contexts/JobContextType.ts deleted file mode 100644 index 0bddc6b8..00000000 --- a/frontend/src/shared/contexts/JobContextType.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { createContext } from 'react'; -import * as backendApi from '@/shared/services/backendApi'; - -/** Job execution status. */ -export type JobStatus = 'Pending' | 'Running' | 'Paused' | 'Completed' | 'Failed' | 'Cancelled'; - -/** Single log entry from job execution. */ -export interface LogEntry { - message: string; - level?: string; - timestamp?: string; - /** Row number this log relates to. */ - row?: number; - /** Row processing status (processing, completed, failed). */ - rowStatus?: string; -} - -/** Sheet-level job representing one worksheet being processed. */ -export interface SheetJob { - id: string; - sheetName: string; - status: JobStatus; - currentRow: number; - totalRows: number; - /** Progress percentage (0-100). */ - progress: number; - errorCount: number; - outputPath?: string; - errorMessage?: string; - logs: LogEntry[]; - /** Hangfire background job ID. */ - hangfireJobId?: string; -} - -/** Group job representing one template + workbook + output folder. */ -export interface GroupJob { - id: string; - workbookPath: string; - outputFolder?: string; - status: JobStatus; - /** Aggregate progress percentage. */ - progress: number; - errorCount: number; - /** Sheet jobs keyed by sheet ID. */ - sheets: Record; - logs: LogEntry[]; - createdAt?: string; - completedAt?: string; -} - -/** Payload for creating a new group job. */ -export interface CreateGroupPayload { - templatePath: string; - spreadsheetPath: string; - outputPath: string; - textConfigs: backendApi.SlideTextConfig[]; - imageConfigs: backendApi.SlideImageConfig[]; - /** Specific sheets to process; omit for all sheets. */ - sheetNames?: string[]; -} - -/** Job context value for managing slide generation jobs. */ -export interface JobContextValue { - /** All group jobs. */ - groups: GroupJob[]; - /** Create a new group job. */ - createGroup: (payload: CreateGroupPayload) => Promise; - /** Refresh all groups from backend. */ - refreshGroups: () => Promise; - /** Remove all completed/failed/cancelled groups. */ - clearCompleted: () => Promise; - /** Control a group job (Pause, Resume, Stop, Cancel). */ - groupControl: (groupId: string, action: backendApi.ControlAction) => Promise; - /** Control a sheet job. */ - jobControl: (jobId: string, action: backendApi.ControlAction) => Promise; - /** Remove a group from UI and backend. */ - removeGroup: (groupId: string) => Promise; - /** Remove a sheet from UI and backend. */ - removeSheet: (jobId: string) => Promise; - /** Load logs for a sheet job. */ - loadSheetLogs: (jobId: string) => Promise; - /** Control all jobs globally. */ - globalControl: (action: backendApi.ControlAction) => Promise; - /** Export group config to JSON file. */ - exportGroupConfig: (groupId: string) => Promise; - /** Check if group has stored config. */ - hasGroupConfig: (groupId: string) => boolean; -} - -export const JobContext = createContext(undefined); diff --git a/frontend/src/shared/contexts/UpdaterContext.tsx b/frontend/src/shared/contexts/UpdaterContext.tsx deleted file mode 100644 index 99a19d9a..00000000 --- a/frontend/src/shared/contexts/UpdaterContext.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import React, { createContext, useContext, useEffect, useMemo, useState, ReactNode } from 'react'; -import { useJobs } from '@/shared/contexts/useJobs'; -import type { JobStatus } from '@/shared/contexts/JobContextType'; - -type UpdateStatus = - | 'idle' - | 'checking' - | 'available' - | 'not-available' - | 'downloading' - | 'downloaded' - | 'error' - | 'unsupported'; - -interface UpdateInfo { - version: string; - releaseNotes?: string; -} - -interface UpdateState { - status: UpdateStatus; - info?: UpdateInfo; - progress?: number; - error?: string; -} - -interface UpdaterContextType { - state: UpdateState; - portable: boolean; - checkForUpdates: () => Promise; - downloadUpdate: () => Promise; - installUpdate: () => void; - hasActiveJobs: boolean; -} - -const UpdaterContext = createContext(null); - -const FINISHED_STATUSES: JobStatus[] = ['Completed', 'Failed', 'Cancelled']; - -export const UpdaterProvider: React.FC<{ children: ReactNode }> = ({ children }) => { - const { groups } = useJobs(); - const [state, setState] = useState({ status: 'idle' }); - const [portable, setPortable] = useState(false); - - const hasActiveJobs = useMemo(() => { - return groups.some((group) => { - if (!FINISHED_STATUSES.includes(group.status)) return true; - return Object.values(group.sheets).some((sheet) => !FINISHED_STATUSES.includes(sheet.status)); - }); - }, [groups]); - - useEffect(() => { - let mounted = true; - (async () => { - if (window.electronAPI?.isPortable) { - const v = await window.electronAPI.isPortable(); - if (mounted) setPortable(Boolean(v)); - } - })(); - return () => { - mounted = false; - }; - }, []); - - useEffect(() => { - if (!window.electronAPI?.onUpdateStatus) return; - const unsubscribe = window.electronAPI.onUpdateStatus((newState) => { - setState((prev) => ({ - ...prev, - status: newState.status as UpdateStatus, - info: newState.info as UpdateInfo | undefined, - progress: newState.progress, - error: newState.error, - })); - }); - return unsubscribe; - }, []); - - const checkForUpdates = async () => { - if (portable) return; - if (!window.electronAPI?.checkForUpdates) return; - - setState((prev) => ({ ...prev, status: 'checking' })); - try { - const result = await window.electronAPI.checkForUpdates(); - setState({ - status: result.status as UpdateStatus, - info: result.info as UpdateInfo | undefined, - error: result.error, - }); - } catch (error) { - setState({ - status: 'error', - error: error instanceof Error ? error.message : 'Unknown error', - }); - } - }; - - const downloadUpdate = async () => { - if (portable) return; - if (!window.electronAPI?.downloadUpdate) return; - - setState((prev) => ({ ...prev, status: 'downloading', progress: 0 })); - await window.electronAPI.downloadUpdate(); - }; - - const installUpdate = () => { - if (portable) return; - if (!window.electronAPI?.installUpdate) return; - if (hasActiveJobs) return; - - window.electronAPI.installUpdate(); - }; - - const value = useMemo( - () => ({ - state, - portable, - checkForUpdates, - downloadUpdate, - installUpdate, - hasActiveJobs, - }), - [state, portable, hasActiveJobs], - ); - - return {children}; -}; - -export const useUpdater = (): UpdaterContextType => { - const context = useContext(UpdaterContext); - if (!context) { - throw new Error('useUpdater must be used within an UpdaterProvider'); - } - return context; -}; diff --git a/frontend/src/shared/contexts/hooks/index.ts b/frontend/src/shared/contexts/hooks/index.ts deleted file mode 100644 index 6129b845..00000000 --- a/frontend/src/shared/contexts/hooks/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { useJobProvider } from './useJobProvider'; diff --git a/frontend/src/shared/contexts/hooks/useJobProvider.ts b/frontend/src/shared/contexts/hooks/useJobProvider.ts deleted file mode 100644 index f9d32fb8..00000000 --- a/frontend/src/shared/contexts/hooks/useJobProvider.ts +++ /dev/null @@ -1,524 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import * as backendApi from '@/shared/services/backendApi'; -import { loggers } from '@/shared/services/logging'; -import type { - GroupJob, - SheetJob, - LogEntry, - JobStatus, - CreateGroupPayload, - JobContextValue, -} from '../JobContextType'; -import { - createEmptyGroup, - createEmptySheet, - trimLogs, - applyGroupTimestamps, - handleSlideNotification, - type SlideNotificationContext, - saveGroupConfigToStorage, - removeGroupConfigFromStorage, - getGroupConfigFromStorage, - clearGroupMetaFromStorage, - saveGroupMetaToStorage, -} from '../utils'; - -export const useJobProvider = (): JobContextValue => { - const [groupsById, setGroupsById] = useState>({}); - const groupsRef = useRef>({}); - const subscribedGroups = useRef(new Set()); - const subscribedSheets = useRef(new Set()); - const sheetToGroup = useRef>({}); - const removedGroupIds = useRef(new Set()); - - const updateGroup = useCallback((groupId: string, updater: (group: GroupJob) => GroupJob) => { - setGroupsById((prev) => { - const current = prev[groupId] ?? createEmptyGroup(groupId); - const updated = applyGroupTimestamps(current, updater(current)); - return { ...prev, [groupId]: updated }; - }); - }, []); - - const updateSheet = useCallback((sheetId: string, updater: (sheet: SheetJob) => SheetJob) => { - const groupId = sheetToGroup.current[sheetId]; - if (!groupId) return; - - setGroupsById((prev) => { - const group = prev[groupId]; - if (!group) return prev; - - const currentSheet = group.sheets[sheetId] ?? createEmptySheet(sheetId); - const updatedSheet = updater(currentSheet); - const updatedGroup: GroupJob = { - ...group, - sheets: { ...group.sheets, [sheetId]: updatedSheet }, - }; - return { ...prev, [groupId]: updatedGroup }; - }); - }, []); - - const saveGroupConfig = useCallback((groupId: string, payload: CreateGroupPayload) => { - saveGroupConfigToStorage(groupId, payload); - }, []); - - const removeGroupConfig = useCallback((groupIds: string[]) => { - removeGroupConfigFromStorage(groupIds); - }, []); - - const getGroupConfig = useCallback((groupId: string): CreateGroupPayload | null => { - return getGroupConfigFromStorage(groupId); - }, []); - - const clearGroupMeta = useCallback((groupIds: string[]) => { - clearGroupMetaFromStorage(groupIds); - }, []); - - const saveGroupMeta = useCallback((summaries: backendApi.GroupSummary[]) => { - saveGroupMetaToStorage(summaries); - }, []); - - const resolveGroupConfig = useCallback( - async (groupId: string): Promise => { - const stored = getGroupConfig(groupId); - if (stored) return stored; - - const payload = await backendApi.getGroupPayload(groupId); - if (!payload) return null; - - const resolved: CreateGroupPayload = { - templatePath: payload.templatePath, - spreadsheetPath: payload.spreadsheetPath, - outputPath: payload.outputPath, - textConfigs: payload.textConfigs ?? [], - imageConfigs: payload.imageConfigs ?? [], - sheetNames: payload.sheetNames, - }; - saveGroupConfig(groupId, resolved); - return resolved; - }, - [getGroupConfig, saveGroupConfig], - ); - - const ensureGroupSubscription = useCallback(async (groupId: string) => { - if (subscribedGroups.current.has(groupId)) return; - await backendApi.subscribeGroup(groupId); - subscribedGroups.current.add(groupId); - }, []); - - const ensureSheetSubscription = useCallback(async (sheetId: string) => { - if (subscribedSheets.current.has(sheetId)) return; - await backendApi.subscribeSheet(sheetId); - subscribedSheets.current.add(sheetId); - }, []); - - const upsertGroupFromSummary = useCallback( - (summary: backendApi.GroupSummary) => { - updateGroup(summary.groupId, (group) => { - return { - ...group, - id: summary.groupId, - workbookPath: summary.workbookPath ?? group.workbookPath, - outputFolder: summary.outputFolder ?? group.outputFolder, - status: summary.status as JobStatus, - progress: summary.progress ?? group.progress, - errorCount: summary.errorCount ?? group.errorCount, - }; - }); - }, - [updateGroup], - ); - - const syncGroupStatus = useCallback( - async (groupId: string) => { - const response = await backendApi.groupStatus({ groupId: groupId }); - const status = response as backendApi.SlideGroupStatusSuccess; - const jobs = status.jobs ?? {}; - - updateGroup(groupId, (group) => { - const sheets: Record = { ...group.sheets }; - Object.values(jobs).forEach((job) => { - const sheetId = job.jobId; - sheetToGroup.current[sheetId] = groupId; - sheets[sheetId] = { - id: sheetId, - sheetName: job.sheetName, - status: job.status as JobStatus, - currentRow: job.currentRow ?? 0, - totalRows: job.totalRows ?? 0, - progress: job.progress ?? 0, - errorCount: job.errorCount ?? 0, - outputPath: job.outputPath ?? sheets[sheetId]?.outputPath, - errorMessage: job.errorMessage ?? undefined, - logs: sheets[sheetId]?.logs ?? [], - hangfireJobId: job.hangfireJobId ?? sheets[sheetId]?.hangfireJobId, - }; - }); - - return { - ...group, - status: status.status as JobStatus, - progress: status.progress ?? group.progress, - errorCount: status.errorCount ?? group.errorCount, - sheets, - }; - }); - - await ensureGroupSubscription(groupId); - await Promise.all(Object.keys(jobs).map((jobId) => ensureSheetSubscription(jobId))); - }, - [ensureGroupSubscription, ensureSheetSubscription, updateGroup], - ); - - const refreshGroups = useCallback(async () => { - const response = await backendApi.getAllGroups(); - const data = response as backendApi.SlideGlobalGetGroupsSuccess; - const summaries = data.groups ?? []; - - summaries.forEach((summary) => { - if (!removedGroupIds.current.has(summary.groupId)) { - upsertGroupFromSummary(summary); - } - }); - - saveGroupMeta(summaries); - - await Promise.allSettled( - summaries.map((summary) => { - if (!removedGroupIds.current.has(summary.groupId)) { - return syncGroupStatus(summary.groupId); - } - return Promise.resolve(); - }), - ); - }, [syncGroupStatus, upsertGroupFromSummary, saveGroupMeta]); - - const createGroup = useCallback( - async (payload: CreateGroupPayload) => { - const response = await backendApi.createGroup({ - templatePath: payload.templatePath, - spreadsheetPath: payload.spreadsheetPath, - outputPath: payload.outputPath, - textConfigs: payload.textConfigs, - imageConfigs: payload.imageConfigs, - sheetNames: payload.sheetNames, - }); - - const data = response as backendApi.SlideGroupCreateSuccess; - const groupId = data.groupId; - removedGroupIds.current.delete(groupId); - saveGroupConfig(groupId, payload); - - let createdGroup: GroupJob = createEmptyGroup(groupId); - updateGroup(groupId, (group) => { - const sheets: Record = { ...group.sheets }; - Object.entries(data.jobIds ?? {}).forEach(([sheetName, jobId]) => { - sheetToGroup.current[jobId] = groupId; - sheets[jobId] = { - ...(sheets[jobId] ?? createEmptySheet(jobId)), - id: jobId, - sheetName, - }; - }); - - createdGroup = { - ...group, - id: groupId, - workbookPath: payload.spreadsheetPath, - outputFolder: data.outputFolder, - status: 'Running', - progress: 0, - errorCount: 0, - sheets, - }; - return createdGroup; - }); - - await ensureGroupSubscription(groupId); - await Promise.all( - Object.values(data.jobIds ?? {}).map((jobId) => ensureSheetSubscription(jobId)), - ); - - await syncGroupStatus(groupId); - - return createdGroup; - }, - [ - ensureGroupSubscription, - ensureSheetSubscription, - saveGroupConfig, - syncGroupStatus, - updateGroup, - ], - ); - - const groupControl = useCallback( - async (groupId: string, action: backendApi.ControlAction) => { - await backendApi.groupControl({ groupId: groupId, action: action }); - if (action === 'Stop' || action === 'Cancel') { - clearGroupMeta([groupId]); - } - await refreshGroups(); - }, - [clearGroupMeta, refreshGroups], - ); - - const removeGroup = useCallback( - async (groupId: string) => { - try { - await backendApi.removeGroup({ groupId: groupId }); - } catch (error) { - loggers.jobs.error(`Failed to remove group ${groupId}:`, error); - } - - setGroupsById((prev) => { - const next = { ...prev }; - delete next[groupId]; - return next; - }); - - removedGroupIds.current.add(groupId); - clearGroupMeta([groupId]); - removeGroupConfig([groupId]); - return true; - }, - [clearGroupMeta, removeGroupConfig], - ); - - const jobControl = useCallback( - async (jobId: string, action: backendApi.ControlAction) => { - await backendApi.jobControl({ jobId: jobId, action: action }); - await refreshGroups(); - }, - [refreshGroups], - ); - - const loadSheetLogs = useCallback( - async (jobId: string) => { - try { - const response = await backendApi.getJobLogs({ jobId: jobId }); - const data = response as backendApi.SlideJobLogsSuccess; - const logs = data.logs.map((entry) => { - const rowValue = entry.data ? entry.data.row : undefined; - const row = typeof rowValue === 'number' ? rowValue : Number(rowValue); - const rowStatusValue = entry.data ? entry.data.rowStatus : undefined; - return { - message: entry.message, - level: entry.level, - timestamp: entry.timestamp, - row: Number.isFinite(row) ? row : undefined, - rowStatus: typeof rowStatusValue === 'string' ? rowStatusValue : undefined, - } satisfies LogEntry; - }); - - updateSheet(jobId, (sheet) => { - if (sheet.logs.length > 0) return sheet; - return { ...sheet, logs: trimLogs(logs) }; - }); - } catch (error) { - loggers.jobs.error('Failed to load job logs:', error); - } - }, - [updateSheet], - ); - - const removeSheet = useCallback(async (jobId: string) => { - const response = await backendApi.removeJob({ jobId: jobId }); - const data = response as backendApi.SlideJobRemoveSuccess; - if (!data.removed) return false; - - setGroupsById((prev) => { - const next: Record = {}; - Object.values(prev).forEach((group) => { - if (!group.sheets[jobId]) { - next[group.id] = group; - return; - } - - const sheets = { ...group.sheets }; - delete sheets[jobId]; - if (Object.keys(sheets).length === 0) return; - - next[group.id] = { ...group, sheets }; - }); - - return next; - }); - - return true; - }, []); - - const globalControl = useCallback(async (action: backendApi.ControlAction) => { - await backendApi.globalControl({ action: action }); - }, []); - - const clearCompleted = useCallback(async () => { - const current = groupsRef.current; - const completedIds = Object.values(current) - .filter((group) => ['completed', 'failed', 'cancelled'].includes(group.status.toLowerCase())) - .map((group) => group.id); - - if (completedIds.length === 0) return; - - const removedIds: string[] = []; - for (const groupId of completedIds) { - try { - await backendApi.removeGroup({ groupId: groupId }); - removedIds.push(groupId); - } catch (error) { - loggers.jobs.error(`Failed to remove group ${groupId}:`, error); - removedIds.push(groupId); - } - } - - if (removedIds.length === 0) return; - - setGroupsById((prev) => { - const next = { ...prev }; - removedIds.forEach((groupId) => { - delete next[groupId]; - removedGroupIds.current.add(groupId); - }); - return next; - }); - - clearGroupMeta(removedIds); - removeGroupConfig(removedIds); - }, [clearGroupMeta, removeGroupConfig]); - - const exportGroupConfig = useCallback( - async (groupId: string) => { - if (!window.electronAPI) return false; - const config = await resolveGroupConfig(groupId); - if (!config) return false; - const exportPayload = { - pptxPath: config.templatePath, - dataPath: config.spreadsheetPath, - savePath: config.outputPath, - selectedSheets: config.sheetNames, - textReplacements: (config.textConfigs ?? []).map((item, index) => ({ - id: index + 1, - placeholder: item.pattern, - columns: item.columns, - })), - imageReplacements: (config.imageConfigs ?? []).map((item, index) => ({ - id: index + 1, - shapeId: String(item.shapeId), - columns: item.columns, - roiType: item.roiType ?? 'RuleOfThirds', - cropType: item.cropType ?? 'Fit', - })), - }; - - const path = await window.electronAPI.saveFile([ - { name: 'JSON Files', extensions: ['json'] }, - { name: 'All Files', extensions: ['*'] }, - ]); - if (!path) return false; - - await window.electronAPI.writeSettings(path, JSON.stringify(exportPayload, null, 2)); - return true; - }, - [resolveGroupConfig], - ); - - const hasGroupConfig = useCallback( - (groupId: string) => { - if (getGroupConfig(groupId)) return true; - return Boolean(groupsRef.current[groupId]); - }, - [getGroupConfig], - ); - - useEffect(() => { - groupsRef.current = groupsById; - }, [groupsById]); - - useEffect(() => { - if (!window.electronAPI?.setProgressBar) return; - const activeGroups = Object.values(groupsById).filter((group) => - ['pending', 'running', 'paused'].includes(group.status.toLowerCase()), - ); - - if (activeGroups.length === 0) { - window.electronAPI.setProgressBar(-1); - return; - } - - const avgProgress = - activeGroups.reduce((sum, group) => sum + (group.progress ?? 0), 0) / activeGroups.length; - const normalized = Math.max(0, Math.min(1, avgProgress / 100)); - window.electronAPI.setProgressBar(normalized); - }, [groupsById]); - - useEffect(() => { - const context: SlideNotificationContext = { - updateGroup, - updateSheet, - removedGroupIds, - sheetToGroup, - groupsRef, - }; - const unsubscribe = backendApi.onSlideNotification((payload) => - handleSlideNotification(payload, context), - ); - - return unsubscribe; - }, [updateGroup, updateSheet]); - - const handleSlideConnected = useCallback(() => { - subscribedGroups.current.clear(); - subscribedSheets.current.clear(); - refreshGroups().catch((error) => { - loggers.jobs.error('Failed to refresh jobs after reconnect:', error); - }); - }, [refreshGroups]); - - useEffect(() => { - const unsubscribeConnected = backendApi.onSlideConnected(handleSlideConnected); - const unsubscribeReconnected = backendApi.onSlideReconnected(handleSlideConnected); - - return () => { - unsubscribeConnected(); - unsubscribeReconnected(); - }; - }, [handleSlideConnected]); - - useEffect(() => { - refreshGroups().catch((error) => { - loggers.jobs.error('Failed to refresh jobs:', error); - }); - }, [refreshGroups]); - - const groups = useMemo(() => Object.values(groupsById), [groupsById]); - - return useMemo( - () => ({ - groups, - createGroup, - refreshGroups, - clearCompleted, - groupControl, - jobControl, - removeGroup, - removeSheet, - loadSheetLogs, - globalControl, - exportGroupConfig, - hasGroupConfig, - }), - [ - clearCompleted, - createGroup, - exportGroupConfig, - groupControl, - groups, - hasGroupConfig, - jobControl, - removeGroup, - removeSheet, - loadSheetLogs, - globalControl, - refreshGroups, - ], - ); -}; diff --git a/frontend/src/shared/contexts/useApp.ts b/frontend/src/shared/contexts/useApp.ts deleted file mode 100644 index 26347f28..00000000 --- a/frontend/src/shared/contexts/useApp.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { useContext } from 'react'; -import { AppContext } from './AppContextType'; - -/** - * Hook to access app settings and translation function. - * - * @returns App context with theme, language, settings, and `t()` function. - * @throws Error if used outside AppProvider. - */ -export const useApp = () => { - const context = useContext(AppContext); - if (!context) { - throw new Error('useApp must be used within AppProvider'); - } - return context; -}; diff --git a/frontend/src/shared/contexts/useJobs.ts b/frontend/src/shared/contexts/useJobs.ts deleted file mode 100644 index d892d925..00000000 --- a/frontend/src/shared/contexts/useJobs.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { useContext } from 'react'; -import { JobContext } from './JobContextType'; - -/** - * Hook to access job management functions and state. - * - * @returns Job context with groups, control functions, and utilities. - * @throws Error if used outside JobProvider. - */ -export const useJobs = () => { - const context = useContext(JobContext); - if (!context) { - throw new Error('useJobs must be used within JobProvider'); - } - return context; -}; diff --git a/frontend/src/shared/contexts/utils/index.ts b/frontend/src/shared/contexts/utils/index.ts deleted file mode 100644 index 24cbb3f1..00000000 --- a/frontend/src/shared/contexts/utils/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './jobUtils'; diff --git a/frontend/src/shared/contexts/utils/jobUtils.ts b/frontend/src/shared/contexts/utils/jobUtils.ts deleted file mode 100644 index 97509a0e..00000000 --- a/frontend/src/shared/contexts/utils/jobUtils.ts +++ /dev/null @@ -1,423 +0,0 @@ -import { loggers } from '@/shared/services/logging'; -import type { - GroupJob, - SheetJob, - LogEntry, - JobStatus, - CreateGroupPayload, -} from '../JobContextType'; - -/** Maximum log entries to keep per job. */ -export const MAX_LOG_ENTRIES = 2000; - -/** Threshold for triggering log trim (higher than MAX to batch operations). */ -const LOG_TRIM_THRESHOLD = 2500; - -/** - * Creates an empty group job with default values. - * - * @param groupId - Unique group identifier. - * @returns New GroupJob with pending status. - */ -export const createEmptyGroup = (groupId: string): GroupJob => ({ - id: groupId, - workbookPath: '', - status: 'Pending', - progress: 0, - errorCount: 0, - sheets: {}, - logs: [], -}); - -/** - * Creates an empty sheet job with default values. - * - * @param sheetId - Unique sheet identifier. - * @returns New SheetJob with pending status. - */ -export const createEmptySheet = (sheetId: string): SheetJob => ({ - id: sheetId, - sheetName: sheetId, - status: 'Pending', - currentRow: 0, - totalRows: 0, - progress: 0, - errorCount: 0, - logs: [], - hangfireJobId: undefined, -}); - -/** - * Trims logs to {@link MAX_LOG_ENTRIES}, keeping the most recent. - * - * @param logs - Log entries to trim. - * @returns Trimmed array (same reference if no trim needed). - */ -export const trimLogs = (logs: LogEntry[]): LogEntry[] => { - if (logs.length <= MAX_LOG_ENTRIES) return logs; - return logs.slice(logs.length - MAX_LOG_ENTRIES); -}; - -/** - * Appends a log entry, trimming when threshold is exceeded. - * - * @param logs - Existing log entries. - * @param entry - New entry to append. - * @returns New array with entry appended. - */ -export const appendLog = (logs: LogEntry[], entry: LogEntry): LogEntry[] => { - if (logs.length < LOG_TRIM_THRESHOLD) { - return [...logs, entry]; - } - return [...logs.slice(logs.length - MAX_LOG_ENTRIES + 1), entry]; -}; - -/** - * Applies createdAt/completedAt timestamps to a group. - * - * @param prev - Previous group state. - * @param next - Next group state. - * @returns Group with timestamps applied. - */ -export const applyGroupTimestamps = (prev: GroupJob, next: GroupJob): GroupJob => { - const createdAt = next.createdAt ?? prev.createdAt ?? new Date().toISOString(); - const isCompleted = ['completed', 'failed', 'cancelled'].includes(next.status.toLowerCase()); - const completedAt = - next.completedAt ?? prev.completedAt ?? (isCompleted ? new Date().toISOString() : undefined); - return { ...next, createdAt, completedAt }; -}; - -/** - * Creates a LogEntry from notification payload data. - * - * @param message - Log message. - * @param level - Log level (defaults to 'Info'). - * @param timestamp - ISO timestamp. - * @param payloadData - Additional data containing row info. - * @returns Formatted LogEntry. - */ -export const createLogEntryFromPayload = ( - message: string, - level: string | undefined, - timestamp: string | undefined, - payloadData: Record | undefined, -): LogEntry => { - const rowValue = payloadData?.row; - const row = typeof rowValue === 'number' ? rowValue : Number(rowValue); - const rowStatusValue = payloadData?.rowStatus; - return { - message, - level: level ?? 'Info', - timestamp, - row: Number.isFinite(row) ? row : undefined, - rowStatus: typeof rowStatusValue === 'string' ? rowStatusValue : undefined, - }; -}; - -/** Ref-like object for mutable values. */ -export type RefLike = { current: T }; - -/** Context for handling SignalR notifications. */ -export type SlideNotificationContext = { - updateGroup: (groupId: string, updater: (group: GroupJob) => GroupJob) => void; - updateSheet: (sheetId: string, updater: (sheet: SheetJob) => SheetJob) => void; - removedGroupIds: RefLike>; - sheetToGroup: RefLike>; - groupsRef: RefLike>; -}; - -/** Parsed notification payload from SignalR. */ -export type SlideNotificationPayload = { - data: Record; - groupId?: string; - jobId?: string; - status?: string; - message?: string; - error?: string; - level?: string; - timestamp?: string; - payloadData?: Record; -}; - -export const parseSlideNotificationPayload = ( - payload: unknown, -): SlideNotificationPayload | null => { - if (!payload || typeof payload !== 'object') return null; - const data = payload as Record; - return { - data, - groupId: data.groupId as string | undefined, - jobId: data.jobId as string | undefined, - status: data.status as string | undefined, - message: data.message as string | undefined, - error: data.error as string | undefined, - level: data.level as string | undefined, - timestamp: data.timestamp as string | undefined, - payloadData: data.data as Record | undefined, - }; -}; - -export const handleGroupProgressNotification = ( - groupId: string, - data: Record, - updateGroup: (groupId: string, updater: (group: GroupJob) => GroupJob) => void, -) => { - if (typeof data.progress !== 'number') return false; - updateGroup(groupId, (group) => ({ - ...group, - progress: data.progress as number, - errorCount: (data.errorCount as number) ?? group.errorCount, - })); - return true; -}; - -export const handleGroupStatusNotification = ( - groupId: string, - status: string, - updateGroup: (groupId: string, updater: (group: GroupJob) => GroupJob) => void, -) => { - updateGroup(groupId, (group) => ({ ...group, status: status as JobStatus })); - return true; -}; - -export const handleSheetProgressNotification = ( - jobId: string, - data: Record, - updateSheet: (sheetId: string, updater: (sheet: SheetJob) => SheetJob) => void, -) => { - if (typeof data.currentRow !== 'number') return false; - updateSheet(jobId, (sheet) => ({ - ...sheet, - currentRow: data.currentRow as number, - totalRows: (data.totalRows as number) ?? sheet.totalRows, - progress: (data.progress as number) ?? sheet.progress, - errorCount: (data.errorCount as number) ?? sheet.errorCount, - })); - return true; -}; - -export const handleSheetStatusNotification = ( - jobId: string, - status: string, - message: string | undefined, - updateSheet: (sheetId: string, updater: (sheet: SheetJob) => SheetJob) => void, -) => { - updateSheet(jobId, (sheet) => ({ - ...sheet, - status: status as JobStatus, - errorMessage: message ?? sheet.errorMessage, - })); - return true; -}; - -export const handleSheetErrorNotification = ( - jobId: string, - error: string, - timestamp: string | undefined, - updateSheet: (sheetId: string, updater: (sheet: SheetJob) => SheetJob) => void, -) => { - const logEntry: LogEntry = { - message: error, - level: 'Error', - timestamp, - }; - updateSheet(jobId, (sheet) => ({ - ...sheet, - logs: appendLog(sheet.logs, logEntry), - })); - return true; -}; - -export const shouldIgnoreNotification = ( - payload: SlideNotificationPayload, - context: SlideNotificationContext, -) => { - if (payload.groupId && context.removedGroupIds.current.has(payload.groupId)) return true; - if (!payload.jobId) return false; - const parentGroupId = context.sheetToGroup.current[payload.jobId]; - return Boolean(parentGroupId && context.removedGroupIds.current.has(parentGroupId)); -}; - -export const handleGroupNotifications = ( - payload: SlideNotificationPayload, - context: SlideNotificationContext, -) => { - if (!payload.groupId) return false; - if (handleGroupProgressNotification(payload.groupId, payload.data, context.updateGroup)) { - return true; - } - if (!payload.status) return false; - handleGroupStatusNotification(payload.groupId, payload.status, context.updateGroup); - return true; -}; - -export const handleSheetNotifications = ( - payload: SlideNotificationPayload, - context: SlideNotificationContext, -) => { - if (!payload.jobId) return false; - if (handleSheetProgressNotification(payload.jobId, payload.data, context.updateSheet)) - return true; - if (payload.status) { - handleSheetStatusNotification( - payload.jobId, - payload.status, - payload.message, - context.updateSheet, - ); - return true; - } - if (payload.error) { - handleSheetErrorNotification( - payload.jobId, - payload.error, - payload.timestamp, - context.updateSheet, - ); - return true; - } - return false; -}; - -export const handleLogNotification = ( - payload: SlideNotificationPayload, - context: SlideNotificationContext, -) => { - if (!payload.jobId || !payload.message) return; - const logEntry = createLogEntryFromPayload( - payload.message, - payload.level, - payload.timestamp, - payload.payloadData, - ); - const targetGroupId = context.sheetToGroup.current[payload.jobId]; - if (targetGroupId) { - context.updateSheet(payload.jobId, (sheet) => ({ - ...sheet, - logs: appendLog(sheet.logs, logEntry), - })); - return; - } - if (context.groupsRef.current[payload.jobId]) { - context.updateGroup(payload.jobId, (group) => ({ - ...group, - logs: appendLog(group.logs, logEntry), - })); - } -}; - -export const handleSlideNotification = (payload: unknown, context: SlideNotificationContext) => { - const parsed = parseSlideNotificationPayload(payload); - if (!parsed) return; - if (shouldIgnoreNotification(parsed, context)) return; - if (handleGroupNotifications(parsed, context)) return; - if (handleSheetNotifications(parsed, context)) return; - handleLogNotification(parsed, context); -}; - -const GROUP_META_KEY = 'slidegen.group.meta'; -const GROUP_CONFIG_KEY = 'slidegen.group.config'; - -export const readGroupConfigs = (): Record => { - try { - const raw = sessionStorage.getItem(GROUP_CONFIG_KEY); - if (!raw) return {}; - return JSON.parse(raw) as Record; - } catch (error) { - loggers.jobs.error('Failed to read group configs:', error); - return {}; - } -}; - -export const saveGroupConfigToStorage = (groupId: string, payload: CreateGroupPayload) => { - try { - const current = readGroupConfigs(); - current[groupId] = payload; - sessionStorage.setItem(GROUP_CONFIG_KEY, JSON.stringify(current)); - } catch (error) { - loggers.jobs.error('Failed to save group config:', error); - } -}; - -export const removeGroupConfigFromStorage = (groupIds: string[]) => { - try { - const current = readGroupConfigs(); - let changed = false; - groupIds.forEach((groupId) => { - if (groupId in current) { - delete current[groupId]; - changed = true; - } - }); - if (!changed) return; - if (Object.keys(current).length === 0) { - sessionStorage.removeItem(GROUP_CONFIG_KEY); - } else { - sessionStorage.setItem(GROUP_CONFIG_KEY, JSON.stringify(current)); - } - } catch (error) { - loggers.jobs.error('Failed to remove group configs:', error); - } -}; - -export const getGroupConfigFromStorage = (groupId: string): CreateGroupPayload | null => { - const current = readGroupConfigs(); - return current[groupId] ?? null; -}; - -export const clearGroupMetaFromStorage = (groupIds: string[]) => { - try { - const raw = sessionStorage.getItem(GROUP_META_KEY); - if (!raw) return; - const parsed = JSON.parse(raw) as Record; - let changed = false; - groupIds.forEach((groupId) => { - if (groupId in parsed) { - delete parsed[groupId]; - changed = true; - } - }); - if (!changed) return; - const nextKeys = Object.keys(parsed); - if (nextKeys.length === 0) { - sessionStorage.removeItem(GROUP_META_KEY); - } else { - sessionStorage.setItem(GROUP_META_KEY, JSON.stringify(parsed)); - } - } catch (error) { - loggers.jobs.error('Failed to clear group meta:', error); - } -}; - -export const saveGroupMetaToStorage = ( - summaries: { - groupId: string; - workbookPath: string; - outputFolder?: string; - status: string; - progress: number; - sheetCount: number; - completedSheets: number; - errorCount?: number; - }[], -) => { - try { - const metaMap: Record = {}; - summaries.forEach((summary) => { - metaMap[summary.groupId] = { - groupId: summary.groupId, - workbookPath: summary.workbookPath, - outputFolder: summary.outputFolder ?? undefined, - status: summary.status, - progress: summary.progress, - sheetCount: summary.sheetCount, - completedSheets: summary.completedSheets, - errorCount: summary.errorCount ?? 0, - updatedAt: new Date().toISOString(), - }; - }); - sessionStorage.setItem(GROUP_META_KEY, JSON.stringify(metaMap)); - } catch (error) { - loggers.jobs.error('Failed to save group meta:', error); - } -}; diff --git a/frontend/src/shared/locales/en.ts b/frontend/src/shared/locales/en.ts deleted file mode 100644 index 1ce163f9..00000000 --- a/frontend/src/shared/locales/en.ts +++ /dev/null @@ -1,293 +0,0 @@ -export const en = { - // App - 'app.title': 'Slide Generator', - - // Sidebar titles - 'sideBar.config': 'Configuration', - 'sideBar.createTask': 'Create Task', - 'sideBar.result': 'Results', - 'sideBar.setting': 'Settings', - 'sideBar.about': 'About', - 'sideBar.process': 'Processing', - - // Create Task Menu - 'createTask.title': 'Create Task', - 'createTask.import': 'Import', - 'createTask.export': 'Export', - 'createTask.clearAll': 'Clear All', - 'createTask.confirmClear': 'Are you sure you want to clear all data?', - 'createTask.importConfig': 'Import configuration', - 'createTask.importSuccess': 'Configuration imported successfully.', - 'createTask.importError': 'Failed to import configuration.', - 'createTask.exportConfig': 'Export configuration', - 'createTask.exportSuccess': 'Configuration exported successfully.', - 'createTask.exportError': 'Failed to export configuration.', - 'createTask.templateInfoLabel': 'Template', - 'createTask.textShapeCount': 'Text shapes', - 'createTask.imageShapeCount': 'Image shapes', - 'createTask.dataInfoLabel': 'Data', - 'createTask.sheetCount': 'Sheets', - 'createTask.columnCount': 'Unique columns', - 'createTask.rowCount': 'Total rows', - 'createTask.sheetSelectTitle': 'Sheets to process', - 'createTask.sheetSelectAll': 'Select all', - 'createTask.sheetSelected': 'Selected', - 'createTask.selectedRowCount': 'Total rows', - 'createTask.sheetToggleExpand': 'Expand sheet list', - 'createTask.sheetToggleCollapse': 'Collapse sheet list', - 'createTask.pptxFile': 'PowerPoint template (.pptx, .potx):', - 'createTask.pptxPlaceholder': 'Select or enter template path...', - 'createTask.dataFile': 'Data file (.xlsx, .xlsm):', - 'createTask.dataFilePlural': 'Data files (.xlsx, .xlsm) - multi-select:', - 'createTask.dataPlaceholder': 'Select or enter data file path...', - 'createTask.filesSelected': 'file(s) selected', - 'createTask.loadingColumns': 'Loading columns...', - 'createTask.loadingShapes': 'Loading shapes...', - 'createTask.columnsLoaded': 'Columns loaded.', - 'createTask.columnLoadError': 'Failed to load columns.', - 'createTask.templateLoadError': 'Failed to load placeholders.', - 'createTask.saveLocation': 'Output folder:', - 'createTask.savePlaceholder': 'Select or enter output folder path...', - 'createTask.browse': 'Browse', - 'createTask.start': 'Create Task', - 'createTask.error': - 'Please select valid template, data file, output folder, and add at least one replacement.', - 'createTask.jsonError': 'Failed to read JSON file.', - 'createTask.restoreError': 'Failed to restore saved configuration.', - 'createTask.previewTitle': 'Shape Preview', - 'createTask.previewSize': 'Size', - 'createTask.previewZoom': 'Zoom', - 'createTask.previewReset': 'Reset', - 'createTask.previewSave': 'Save image', - - // Replacement tables - 'replacement.textTitle': 'Text Replacement', - 'replacement.imageTitle': 'Image Replacement', - 'replacement.add': 'Add', - 'replacement.limitReached': 'Limit reached (max)', - 'replacement.availableShapes': 'Available shapes', - 'replacement.noShapes': 'No shapes available', - 'replacement.delete': 'Delete', - 'replacement.searchText': 'Placeholder', - 'replacement.column': 'Data column', - 'replacement.shape': 'Shape', - 'replacement.roi': 'ROI', - 'replacement.crop': 'Crop', - 'replacement.searchPlaceholder': 'Select placeholder...', - 'replacement.columnPlaceholder': 'Enter columns, separated by commas...', - 'replacement.shapePlaceholder': 'Select shape...', - 'replacement.roiRuleOfThirds': 'Rule of Thirds', - 'replacement.roiRuleOfThirdsDesc': - 'Use face detection to anchor image according to Rule Of Thirds.', - 'replacement.roiProminent': 'Prominent', - 'replacement.roiProminentDesc': 'Use Saliency to find the most prominent area.', - 'replacement.roiCenter': 'Center', - 'replacement.roiCenterDesc': 'Use the image center.', - 'replacement.cropCrop': 'Crop', - 'replacement.cropCropDesc': 'Crop to fill the shape.', - 'replacement.cropFit': 'Fit', - 'replacement.cropFitDesc': 'Fit the image inside the shape.', - - // Settings - 'settings.title': 'Settings', - 'settings.appearance': 'Appearance', - 'settings.download': 'Download', - 'settings.server': 'Server', - 'settings.job': 'Jobs', - 'settings.image': 'Image', - 'settings.appearanceSettings': 'Appearance settings', - 'settings.serverSettings': 'Server settings', - 'settings.downloadSettings': 'Download settings', - 'settings.jobSettings': 'Job settings', - 'settings.imageSettings': 'Image settings', - 'settings.theme': 'Theme:', - 'settings.themeDark': 'Dark', - 'settings.themeLight': 'Light', - 'settings.themeSystem': 'System', - 'settings.language': 'Language:', - 'settings.languageVi': 'Tiếng Việt', - 'settings.languageEn': 'English', - 'settings.enableAnimations': 'Enable animations', - 'settings.closeToTray': 'Close to tray', - 'settings.closeToTrayDesc': 'Minimize to tray when clicking close', - 'settings.save': 'Save', - 'settings.reload': 'Refresh', - 'settings.resetToDefaults': 'Reset to defaults', - - // Settings labels - 'settings.host': 'Host', - 'settings.hostHint': 'Server address', - 'settings.port': 'Port', - 'settings.portHint': 'Server port (1-65535)', - 'settings.debugMode': 'Debug mode', - 'settings.debugModeDesc': 'Enable detailed logs for development', - 'settings.saveFolder': 'Download folder', - 'settings.saveFolderHint': 'Where downloaded images are stored', - 'settings.maxChunks': 'Max chunks', - 'settings.maxChunksHint': 'Max chunks per file', - 'settings.speedLimit': 'Speed limit (bytes/s)', - 'settings.speedLimitHint': '0 = unlimited', - 'settings.retryTimeout': 'Retry timeout (sec)', - 'settings.retryTimeoutHint': 'Timeout per retry attempt', - 'settings.maxRetries': 'Max retries', - 'settings.maxRetriesHint': 'Max retry attempts (0-10)', - 'settings.proxySettings': 'Proxy', - 'settings.useProxy': 'Use proxy', - 'settings.useProxyHint': 'Route download traffic through a proxy server', - 'settings.proxyAddress': 'Proxy address', - 'settings.proxyAddressHint': 'Full URL including port (e.g. http://proxy:8080)', - 'settings.proxyUsername': 'Username', - 'settings.proxyPassword': 'Password', - 'settings.proxyDomain': 'Domain', - 'settings.proxyDomainHint': 'For NTLM authentication (Windows domain)', - 'settings.optional': '(optional)', - 'settings.maxConcurrentJobs': 'Max concurrent jobs', - 'settings.maxConcurrentJobsHint': 'Max sheets processed in parallel', - 'settings.imageFace': 'Face detection', - 'settings.imageFaceHint': 'Settings for face detection.', - 'settings.imageSaliency': 'Saliency', - 'settings.imageSaliencyHint': 'Settings for salient areas.', - 'settings.imagePadding': 'Padding', - 'settings.imageConfidence': 'Confidence', - 'settings.imageMaxDimension': 'Max dimension (px)', - 'settings.imageMaxDimensionHint': 'Limit max dimension for face detection (0 = unlimited).', - 'settings.imageUnionAll': 'Union all faces', - 'settings.imageUnionAllDesc': - 'Use center of all faces as anchor when multiple faces are detected.', - 'settings.imageModel': 'Models', - 'settings.imageModelHint': 'Manage AI models for image processing.', - 'settings.faceModel': 'YuNet', - 'settings.modelName': 'Name', - 'settings.modelstatus': 'Status', - 'settings.modelAction': 'Action', - 'settings.modelAvailable': 'Loaded', - 'settings.modelUnavailable': 'Not loaded', - 'settings.modelInit': 'Load', - 'settings.modelDeinit': 'Unload', - 'settings.modelLoading': 'Loading...', - 'settings.modelInitSuccess': 'Model loaded successfully', - 'settings.modelInitError': 'Failed to load model', - 'settings.modelDeinitSuccess': 'Model unloaded successfully', - 'settings.modelDeinitError': 'Failed to unload model', - 'settings.imagePaddingTop': 'Top padding', - 'settings.imagePaddingBottom': 'Bottom padding', - 'settings.imagePaddingLeft': 'Left padding', - 'settings.imagePaddingRight': 'Right padding', - 'settings.imagePaddingHint': 'Value from 0.0 to 1.0', - 'settings.themeHint': 'Choose your theme', - 'settings.languageHint': 'Choose interface language', - 'settings.animationsDesc': 'Smooth motion and transitions', - 'settings.loading': 'Loading configuration...', - 'settings.saving': 'Saving...', - 'settings.loadError': 'Failed to load configuration', - 'settings.saveSuccess': 'Configuration saved.', - 'settings.saveError': 'Failed to save configuration', - 'settings.restartRequired': 'Restart the server to apply server settings.', - 'settings.restartServer': 'Restart server', - 'settings.restartSuccess': 'Server restarted.', - 'settings.restartError': 'Failed to restart server', - 'settings.restartUnavailable': 'Server Restart is only available in the desktop app.', - 'settings.reloadSuccess': 'Configuration refreshed.', - 'settings.reloadError': 'Failed to refresh configuration', - 'settings.resetSuccess': 'Defaults restored.', - 'settings.resetError': 'Failed to restore defaults', - 'settings.confirmReset': 'Are you sure you want to restore defaults?', - 'settings.locked': 'Pause or finish running jobs to edit server settings.', - 'settings.seconds': 'sec', - - // About - 'about.title': 'About', - 'about.appName': 'Slide Generator', - 'about.version': 'Version', - 'about.description': 'Software to generate PowerPoint slides from spreadsheet data.', - 'about.details': 'Made with ASP.NET, React, Electron with ❤️.', - 'about.developer': 'Developer', - 'about.githubRepo': 'GitHub repository', - 'about.readDocs': 'Read documentation', - 'about.license': '© 2026 Mai Thành', - - // Update - 'update.checkForUpdates': 'Check for updates', - 'update.checking': 'Checking for updates...', - 'update.available': 'Update available', - 'update.downloaded': 'Update downloaded successfully', - 'update.notAvailable': 'You are using the latest version', - 'update.portableUnsupported': 'Portable build does not support in-app updates.', - 'update.downloading': 'Downloading update...', - 'update.download': 'Download', - 'update.installNow': 'Install now', - 'update.installLater': 'Install on exit', - 'update.activeJobsWarning': - 'Cannot install while jobs are running. Please wait for all jobs to finish.', - 'update.error': 'Update check failed', - 'update.newVersion': 'New version available:', - 'update.currentVersion': 'Current version:', - 'update.releaseNotes': 'Release notes', - - // Process - 'process.title': 'Processing', - 'process.pause': 'Pause', - 'process.resume': 'Resume', - 'process.stop': 'Stop', - 'process.pauseAll': 'Pause all', - 'process.resumeAll': 'Resume all', - 'process.stopAll': 'Stop all', - 'process.confirmStop': 'Are you sure you want to stop?', - 'process.confirmStopAll': 'Are you sure you want to stop all?', - 'process.viewDetails': 'View details', - 'process.viewLog': 'View log', - 'process.log': 'Log', - 'process.logGeneral': 'General', - 'process.noLogs': 'No logs', - 'process.empty': 'Empty', - 'process.progress': 'Progress', - 'process.status.processing': 'Processing', - 'process.status.paused': 'Paused', - 'process.status.completed': 'Completed', - 'process.status.error': 'Error', - 'process.status.pending': 'Pending', - 'process.status.cancelled': 'Cancelled', - 'process.group': 'Group', - 'process.expand': 'Expand', - 'process.collapse': 'Collapse', - 'process.success': 'Success', - 'process.processing': 'Processing', - 'process.failed': 'Failed', - 'process.files': 'files', - 'process.slides': 'slides', - 'process.successSlides': 'Successful slides', - 'process.processingSlides': 'Pending/Processing slides', - 'process.failedSlides': 'Failed slides', - 'process.createdAt': 'Created at', - 'process.jobId': 'Job ID', - 'process.hangfireId': 'Hangfire ID', - - // Results - 'results.title': 'Results', - 'results.recentOutputs': 'Generated files', - 'results.open': 'Open', - 'results.openFolder': 'Open folder', - 'results.exportConfig': 'Export configuration', - 'results.viewLog': 'View log', - 'results.remove': 'Clear', - 'results.removeGroup': 'Clear group', - 'results.clearAll': 'Clear all', - 'results.confirmRemoveGroup': 'Are you sure you want to clear this group?', - 'results.confirmClearAll': 'Are you sure you want to clear all results?', - 'results.completedAt': 'Completed at', - 'results.empty': 'Empty', - - // Connection - 'connection.disconnected': 'Cannot connect to local server', - 'connection.connected': 'Connected to local server', - - // Tray - 'tray.show': 'Show', - 'tray.hideToTray': 'Minimize to tray', - 'tray.quit': 'Quit', - - // Common - 'common.close': 'Close', - 'common.cancel': 'Cancel', - 'common.ok': 'OK', -}; diff --git a/frontend/src/shared/locales/index.ts b/frontend/src/shared/locales/index.ts deleted file mode 100644 index ec49629b..00000000 --- a/frontend/src/shared/locales/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { vi } from './vi'; -import { en } from './en'; - -export const translations = { - vi, - en, -}; - -export type Language = 'vi' | 'en'; diff --git a/frontend/src/shared/locales/vi.ts b/frontend/src/shared/locales/vi.ts deleted file mode 100644 index f61beedb..00000000 --- a/frontend/src/shared/locales/vi.ts +++ /dev/null @@ -1,290 +0,0 @@ -export const vi = { - // App - 'app.title': 'Slide Generator', - - // Sidebar titles - 'sideBar.config': 'Cấu hình', - 'sideBar.createTask': 'Tạo tác vụ', - 'sideBar.result': 'Kết quả', - 'sideBar.setting': 'Cài đặt', - 'sideBar.about': 'Giới thiệu', - 'sideBar.process': 'Xử lý', - - // Create Task Menu - 'createTask.title': 'Tạo tác vụ', - 'createTask.import': 'Nhập', - 'createTask.export': 'Xuất', - 'createTask.clearAll': 'Xóa tất cả', - 'createTask.confirmClear': 'Bạn có chắc muốn xóa toàn bộ dữ liệu không?', - 'createTask.importConfig': 'Nhập cấu hình', - 'createTask.importSuccess': 'Nhập cấu hình thành công.', - 'createTask.importError': 'Không thể nhập cấu hình.', - 'createTask.exportConfig': 'Xuất cấu hình', - 'createTask.exportSuccess': 'Xuất cấu hình thành công.', - 'createTask.exportError': 'Không thể xuất cấu hình.', - 'createTask.templateInfoLabel': 'Mẫu', - 'createTask.textShapeCount': 'Shape chữ', - 'createTask.imageShapeCount': 'Shape ảnh', - 'createTask.dataInfoLabel': 'Dữ liệu', - 'createTask.sheetCount': 'Số sheet', - 'createTask.columnCount': 'Cột duy nhất', - 'createTask.rowCount': 'Tổng bản ghi', - 'createTask.sheetSelectTitle': 'Sheet cần xử lý', - 'createTask.sheetSelectAll': 'Chọn tất cả', - 'createTask.sheetSelected': 'Đã chọn', - 'createTask.selectedRowCount': 'Tổng bản ghi', - 'createTask.sheetToggleExpand': 'Mở danh sách sheet', - 'createTask.sheetToggleCollapse': 'Thu gọn danh sách sheet', - 'createTask.pptxFile': 'File mẫu PowerPoint (.pptx, .potx):', - 'createTask.pptxPlaceholder': 'Chọn hoặc nhập đường dẫn file mẫu...', - 'createTask.dataFile': 'File dữ liệu (.xlsx, .xlsm):', - 'createTask.dataFilePlural': 'Các file dữ liệu (.xlsx, .xlsm) - có thể chọn nhiều:', - 'createTask.dataPlaceholder': 'Chọn hoặc nhập đường dẫn file dữ liệu...', - 'createTask.filesSelected': 'tệp đã chọn', - 'createTask.loadingColumns': 'Đang tải cột...', - 'createTask.loadingShapes': 'Đang tải shape...', - 'createTask.columnsLoaded': 'Đã tải cột.', - 'createTask.columnLoadError': 'Không tải được cột.', - 'createTask.templateLoadError': 'Không tải được placeholder.', - 'createTask.saveLocation': 'Thư mục lưu:', - 'createTask.savePlaceholder': 'Chọn hoặc nhập thư mục lưu...', - 'createTask.browse': 'Duyệt', - 'createTask.start': 'Tạo tác vụ', - 'createTask.error': - 'Vui lòng chọn file mẫu, dữ liệu, thư mục lưu hợp lệ và có ít nhất 1 cấu hình thay thế.', - 'createTask.restoreError': 'Không thể khôi phục cấu hình đã lưu.', - 'createTask.previewTitle': 'Xem trước Shape', - 'createTask.previewSize': 'Kích thước', - 'createTask.previewZoom': 'Thu phóng', - 'createTask.previewReset': 'Đặt lại', - 'createTask.previewSave': 'Lưu ảnh', - - // Replacement tables - 'replacement.textTitle': 'Thay thế Text', - 'replacement.imageTitle': 'Thay thế Ảnh', - 'replacement.add': 'Thêm', - 'replacement.limitReached': 'Đã vượt quá giới hạn (tối đa)', - 'replacement.availableShapes': 'Danh sách shape', - 'replacement.noShapes': 'Không có shape', - 'replacement.delete': 'Xóa', - 'replacement.searchText': 'Placeholder', - 'replacement.column': 'Cột dữ liệu', - 'replacement.shape': 'Shape', - 'replacement.roi': 'ROI', - 'replacement.crop': 'Crop', - 'replacement.searchPlaceholder': 'Chọn placeholder...', - 'replacement.columnPlaceholder': 'Nhập tên cột, cách nhau bằng dấu phẩy...', - 'replacement.shapePlaceholder': 'Chọn shape...', - 'replacement.roiRuleOfThirds': 'Rule of Thirds', - 'replacement.roiRuleOfThirdsDesc': 'Nhận diện khuôn mặt rồi neo ảnh theo Rule Of Thirds.', - 'replacement.roiProminent': 'Prominent', - 'replacement.roiProminentDesc': 'Chọn vùng nổi bật nhất bằng Saliency.', - 'replacement.roiCenter': 'Center', - 'replacement.roiCenterDesc': 'Chọn vùng trung tâm ảnh.', - 'replacement.cropCrop': 'Crop', - 'replacement.cropCropDesc': 'Cắt ảnh để lấp đầy khung.', - 'replacement.cropFit': 'Fit', - 'replacement.cropFitDesc': 'Fit toàn bộ ảnh vào khung.', - - // Settings - 'settings.title': 'Cài đặt', - 'settings.appearance': 'Giao diện', - 'settings.download': 'Tải xuống', - 'settings.server': 'Server', - 'settings.job': 'Công việc', - 'settings.image': 'Hình ảnh', - 'settings.appearanceSettings': 'Cài đặt giao diện', - 'settings.serverSettings': 'Cài đặt server', - 'settings.downloadSettings': 'Cài đặt tải xuống', - 'settings.jobSettings': 'Cài đặt công việc', - 'settings.imageSettings': 'Cài đặt hình ảnh', - 'settings.theme': 'Giao diện:', - 'settings.themeDark': 'Tối', - 'settings.themeLight': 'Sáng', - 'settings.themeSystem': 'Theo hệ thống', - 'settings.language': 'Ngôn ngữ:', - 'settings.languageVi': 'Tiếng Việt', - 'settings.languageEn': 'English', - 'settings.enableAnimations': 'Bật hiệu ứng', - 'settings.closeToTray': 'Đóng xuống khay', - 'settings.closeToTrayDesc': 'Thu nhỏ xuống khay khi bấm nút đóng', - 'settings.save': 'Lưu', - 'settings.reload': 'Làm mới', - 'settings.resetToDefaults': 'Khôi phục mặc định', - - // Settings labels - 'settings.host': 'Host', - 'settings.hostHint': 'Địa chỉ máy chủ', - 'settings.port': 'Cổng', - 'settings.portHint': 'Cổng máy chủ (1-65535)', - 'settings.debugMode': 'Chế độ debug', - 'settings.debugModeDesc': 'Bật log chi tiết để phát triển', - 'settings.saveFolder': 'Thư mục tải về', - 'settings.saveFolderHint': 'Nơi lưu ảnh tải về', - 'settings.maxChunks': 'Số chunks tối đa', - 'settings.maxChunksHint': 'Số chunks tải mỗi file', - 'settings.speedLimit': 'Giới hạn tốc độ (byte/s)', - 'settings.speedLimitHint': '0 = không giới hạn', - 'settings.retryTimeout': 'Thời gian retry (giây)', - 'settings.retryTimeoutHint': 'Thời gian chờ mỗi lần retry', - 'settings.maxRetries': 'Số lần retry tối đa', - 'settings.maxRetriesHint': 'Số lần thử lại tối đa (0-10)', - 'settings.proxySettings': 'Proxy', - 'settings.useProxy': 'Sử dụng proxy', - 'settings.useProxyHint': 'Định tuyến lưu lượng tải xuống qua máy chủ proxy', - 'settings.proxyAddress': 'Địa chỉ proxy', - 'settings.proxyAddressHint': 'URL đầy đủ bao gồm cổng (ví dụ: http://proxy:8080)', - 'settings.proxyUsername': 'Tên đăng nhập', - 'settings.proxyPassword': 'Mật khẩu', - 'settings.proxyDomain': 'Domain', - 'settings.proxyDomainHint': 'Cho xác thực NTLM (domain Windows)', - 'settings.optional': '(tùy chọn)', - 'settings.maxConcurrentJobs': 'Số job đồng thời', - 'settings.maxConcurrentJobsHint': 'Số sheet xử lý song song', - 'settings.imageFace': 'Nhận diện khuôn mặt', - 'settings.imageFaceHint': 'Cài đặt nhận diện khuôn mặt.', - 'settings.imageSaliency': 'Saliency', - 'settings.imageSaliencyHint': 'Cài đặt nhận diện vùng nổi bật.', - 'settings.imagePadding': 'Padding', - 'settings.imageConfidence': 'Độ tin cậy', - 'settings.imageMaxDimension': 'Kích thước tối đa (px)', - 'settings.imageMaxDimensionHint': - 'Giới hạn kích thước tối đa để nhận diện khuôn mặt (0 = không giới hạn).', - 'settings.imageUnionAll': 'Gộp tất cả khuôn mặt', - 'settings.imageUnionAllDesc': 'Lấy trung tâm các khuôn mặt làm tâm khi có nhiều mặt.', - 'settings.imageModel': 'Mô hình', - 'settings.imageModelHint': 'Quản lý mô hình AI xử lý hình ảnh.', - 'settings.faceModel': 'YuNet', - 'settings.modelName': 'Tên', - 'settings.modelstatus': 'Trạng thái', - 'settings.modelAction': 'Hành động', - 'settings.modelAvailable': 'Đã tải', - 'settings.modelUnavailable': 'Chưa tải', - 'settings.modelInit': 'Tải', - 'settings.modelDeinit': 'Hủy tải', - 'settings.modelLoading': 'Đang tải...', - 'settings.modelInitSuccess': 'Tải mô hình thành công', - 'settings.modelInitError': 'Không thể tải mô hình', - 'settings.modelDeinitSuccess': 'Hủy tải mô hình thành công', - 'settings.modelDeinitError': 'Không thể hủy tải mô hình', - 'settings.imagePaddingTop': 'Padding trên', - 'settings.imagePaddingBottom': 'Padding dưới', - 'settings.imagePaddingLeft': 'Padding trái', - 'settings.imagePaddingRight': 'Padding phải', - 'settings.imagePaddingHint': 'Giá trị 0.0-1.0', - 'settings.themeHint': 'Chọn giao diện màu', - 'settings.languageHint': 'Chọn ngôn ngữ hiển thị', - 'settings.animationsDesc': 'Hiệu ứng chuyển động mượt', - 'settings.loading': 'Đang tải cấu hình...', - 'settings.saving': 'Đang lưu...', - 'settings.loadError': 'Không tải được cấu hình', - 'settings.saveSuccess': 'Đã lưu cấu hình.', - 'settings.saveError': 'Không lưu được cấu hình', - 'settings.restartRequired': 'Cần khởi động lại server để áp dụng cấu hình.', - 'settings.restartServer': 'Khởi động lại server', - 'settings.restartSuccess': 'Đã khởi động lại server.', - 'settings.restartError': 'Không thể khởi động lại server', - 'settings.restartUnavailable': 'Chỉ hỗ trợ Khởi động lại trên ứng dụng desktop.', - 'settings.reloadSuccess': 'Đã làm mới cấu hình.', - 'settings.reloadError': 'Không làm mới được cấu hình', - 'settings.resetSuccess': 'Đã khôi phục mặc định.', - 'settings.resetError': 'Không thể khôi phục mặc định', - 'settings.confirmReset': 'Bạn có chắc muốn khôi phục mặc định không?', - 'settings.locked': 'Hãy tạm dừng hoặc hoàn tất job đang chạy để chỉnh cấu hình.', - 'settings.seconds': 'giây', - - // About - 'about.title': 'Giới thiệu', - 'about.appName': 'Slide Generator', - 'about.version': 'Phiên bản', - 'about.description': 'Phần mềm tạo slide trình chiếu PowerPoint từ dữ liệu bảng tính.', - 'about.details': 'Xây dựng trên ASP.NET, React, Electron với ❤️.', - 'about.developer': 'Nhà phát triển', - 'about.githubRepo': 'Kho GitHub', - 'about.readDocs': 'Đọc tài liệu', - 'about.license': '© 2026 Mai Thành', - // Update - 'update.checkForUpdates': 'Kiểm tra cập nhật', - 'update.checking': 'Đang kiểm tra cập nhật...', - 'update.available': 'Đã có bản cập nhật mới', - 'update.downloaded': 'Đã tải về bản cập nhật mới', - 'update.notAvailable': 'Bạn đang sử dụng phiên bản mới nhất', - 'update.portableUnsupported': 'Bản portable không hỗ trợ cập nhật tự động.', - 'update.downloading': 'Đang tải bản cập nhật...', - 'update.download': 'Tải xuống', - 'update.installNow': 'Cài đặt ngay', - 'update.installLater': 'Cài khi thoát', - 'update.activeJobsWarning': - 'Không thể cài đặt khi còn công việc đang chạy. Vui lòng đợi hoàn thành.', - 'update.error': 'Kiểm tra cập nhật thất bại', - 'update.newVersion': 'Phiên bản mới:', - 'update.currentVersion': 'Phiên bản hiện tại:', - 'update.releaseNotes': 'Ghi chú phát hành', - - // Process - 'process.title': 'Xử lý', - 'process.pause': 'Tạm dừng', - 'process.resume': 'Tiếp tục', - 'process.stop': 'Dừng', - 'process.pauseAll': 'Tạm dừng tất cả', - 'process.resumeAll': 'Tiếp tục tất cả', - 'process.stopAll': 'Dừng tất cả', - 'process.confirmStop': 'Bạn có chắc muốn dừng?', - 'process.confirmStopAll': 'Bạn có chắc muốn dừng tất cả?', - 'process.viewDetails': 'Xem chi tiết', - 'process.viewLog': 'Xem log', - 'process.log': 'Log', - 'process.logGeneral': 'Chung', - 'process.noLogs': 'Chưa có log', - 'process.empty': 'Trống', - 'process.progress': 'Tiến độ', - 'process.status.processing': 'Đang xử lý', - 'process.status.paused': 'Đã tạm dừng', - 'process.status.completed': 'Hoàn thành', - 'process.status.error': 'Lỗi', - 'process.status.pending': 'Đang chờ', - 'process.status.cancelled': 'Đã hủy', - 'process.group': 'Nhóm', - 'process.expand': 'Mở rộng', - 'process.collapse': 'Thu gọn', - 'process.success': 'Thành công', - 'process.processing': 'Đang xử lý', - 'process.failed': 'Thất bại', - 'process.files': 'tệp', - 'process.slides': 'slide', - 'process.successSlides': 'Slide thành công', - 'process.processingSlides': 'Slide đang chờ/đang xử lý', - 'process.failedSlides': 'Slide thất bại', - 'process.createdAt': 'Tạo lúc', - 'process.jobId': 'ID Job', - 'process.hangfireId': 'ID Hangfire', - - // Results - 'results.title': 'Kết quả', - 'results.recentOutputs': 'File đã tạo', - 'results.open': 'Mở', - 'results.openFolder': 'Mở thư mục', - 'results.exportConfig': 'Xuất cấu hình', - 'results.viewLog': 'Xem log', - 'results.remove': 'Bỏ khỏi danh sách', - 'results.removeGroup': 'Bỏ nhóm', - 'results.clearAll': 'Bỏ tất cả', - 'results.confirmRemoveGroup': 'Bạn có chắc muốn bỏ nhóm này?', - 'results.confirmClearAll': 'Bạn có chắc muốn bỏ tất cả?', - 'results.completedAt': 'Hoàn thành lúc', - 'results.empty': 'Trống', - - // Connection - 'connection.disconnected': 'Không thể kết nối tới local server', - 'connection.connected': 'Đã kết nối tới local server', - - // Tray - 'tray.show': 'Hiển thị', - 'tray.hideToTray': 'Thu nhỏ xuống khay', - 'tray.quit': 'Thoát', - - // Common - 'common.close': 'Đóng', - 'common.cancel': 'Hủy', - 'common.ok': 'OK', -}; diff --git a/frontend/src/shared/services/backend/clients.ts b/frontend/src/shared/services/backend/clients.ts deleted file mode 100644 index 576ddafb..00000000 --- a/frontend/src/shared/services/backend/clients.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { SignalRHubClient } from '../signalrClient' - -/** - * SignalR hub client for sheet/workbook operations. - * Connects to `/hubs/sheet` endpoint. - */ -export const sheetHub = new SignalRHubClient('/hubs/sheet') - -/** - * SignalR hub client for job management operations. - * Connects to `/hubs/job` endpoint. - */ -export const jobHub = new SignalRHubClient('/hubs/job') - -/** - * SignalR hub client for configuration operations. - * Connects to `/hubs/config` endpoint. - */ -export const configHub = new SignalRHubClient('/hubs/config') diff --git a/frontend/src/shared/services/backend/common/types.ts b/frontend/src/shared/services/backend/common/types.ts deleted file mode 100644 index 00f56eda..00000000 --- a/frontend/src/shared/services/backend/common/types.ts +++ /dev/null @@ -1,28 +0,0 @@ -export interface ResponseBase { - type?: string - message?: string - kind?: string - filePath?: string -} - -export type ControlAction = 'Pause' | 'Resume' | 'Cancel' | 'Stop' | 'Remove' - -export interface SlideTextConfig { - pattern: string - columns: string[] -} - -export interface SlideImageConfig { - shapeId: number - columns: string[] - roiType?: string | null - cropType?: string | null -} - -export interface ShapeDto { - id: number - name: string - data: string - kind?: string - isImage?: boolean -} diff --git a/frontend/src/shared/services/backend/common/utils.test.ts b/frontend/src/shared/services/backend/common/utils.test.ts deleted file mode 100644 index 35206f19..00000000 --- a/frontend/src/shared/services/backend/common/utils.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { - assertSuccess, - getResponseErrorMessage, - getResponseType, -} from './utils'; - -describe('getResponseType', () => { - it('reads type and normalizes case', () => { - expect(getResponseType({ type: 'Error' })).toBe('error'); - expect(getResponseType({ type: 'SUCCESS' })).toBe('success'); - }); -}); - -describe('getResponseErrorMessage', () => { - it('includes kind and message with file path', () => { - const message = getResponseErrorMessage({ - type: 'error', - kind: 'BadRequest', - message: 'Invalid data', - filePath: 'data.xlsx', - }); - expect(message).toBe('[data.xlsx] BadRequest: Invalid data'); - }); - - it('returns message without repeating kind', () => { - const message = getResponseErrorMessage({ - type: 'error', - kind: 'BadRequest', - message: 'BadRequest: Invalid data', - }); - expect(message).toBe('BadRequest: Invalid data'); - }); -}); - -describe('assertSuccess', () => { - it('returns response when not error', () => { - const response = { type: 'ok', value: 1 }; - expect(assertSuccess(response)).toBe(response); - }); - - it('throws when response is error', () => { - expect(() => - assertSuccess({ - type: 'error', - message: 'Boom', - }), - ).toThrow('Boom'); - }); -}); diff --git a/frontend/src/shared/services/backend/common/utils.ts b/frontend/src/shared/services/backend/common/utils.ts deleted file mode 100644 index 4cb10074..00000000 --- a/frontend/src/shared/services/backend/common/utils.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { ResponseBase } from './types' - -export function getResponseType(response: ResponseBase): string { - return (response.type ?? '').toLowerCase() -} - -export function getResponseErrorMessage(response: ResponseBase): string { - const message = response.message ?? '' - const kind = response.kind ?? '' - const filePath = response.filePath ?? '' - const prefix = filePath ? `[${filePath}] ` : '' - if (message && kind && !message.includes(kind)) { - return `${prefix}${kind}: ${message}` - } - return `${prefix}${message || kind || 'Backend error'}` -} - -export function assertSuccess(response: ResponseBase): T { - if (getResponseType(response) === 'error') { - throw new Error(getResponseErrorMessage(response)) - } - return response as T -} diff --git a/frontend/src/shared/services/backend/config/api.ts b/frontend/src/shared/services/backend/config/api.ts deleted file mode 100644 index 9ae2b13e..00000000 --- a/frontend/src/shared/services/backend/config/api.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { configHub } from '../clients' -import type { ResponseBase } from '../common/types' -import { assertSuccess } from '../common/utils' -import type { - ConfigGetSuccess, - ConfigReloadSuccess, - ConfigResetSuccess, - ConfigUpdateSuccess, - ModelStatusSuccess, - ModelControlSuccess, -} from './types' - -/** - * Retrieves the current backend configuration. - * - * @returns The current configuration settings - */ -export async function getConfig(): Promise { - const response = await configHub.sendRequest({ - type: 'get', - }) - return assertSuccess(response) -} - -/** - * Updates backend configuration with new values. - * - * @param request - Configuration values to update - * @returns Updated configuration confirmation - */ -export async function updateConfig(request: Record): Promise { - const response = await configHub.sendRequest({ - type: 'update', - ...request, - }) - return assertSuccess(response) -} - -/** - * Reloads configuration from the backend config file. - * - * @returns Reload confirmation - */ -export async function reloadConfig(): Promise { - const response = await configHub.sendRequest({ - type: 'reload', - }) - return assertSuccess(response) -} - -/** - * Resets configuration to default values. - * - * @returns Reset confirmation - */ -export async function resetConfig(): Promise { - const response = await configHub.sendRequest({ - type: 'reset', - }) - return assertSuccess(response) -} - -/** - * Gets the initialization status of ML models. - * - * @returns Model status information - */ -export async function getModelStatus(): Promise { - const response = await configHub.sendRequest({ - type: 'modelstatus', - }) - return assertSuccess(response) -} - -/** - * Controls ML model initialization/deinitialization. - * - * @param model - The model identifier - * @param action - Action to perform ('init' or 'deinit') - * @returns Model control confirmation - */ -export async function controlModel( - model: string, - action: 'init' | 'deinit', -): Promise { - const response = await configHub.sendRequest({ - type: 'modelcontrol', - Model: model, - Action: action, - }) - return assertSuccess(response) -} diff --git a/frontend/src/shared/services/backend/config/types.ts b/frontend/src/shared/services/backend/config/types.ts deleted file mode 100644 index c4394f8a..00000000 --- a/frontend/src/shared/services/backend/config/types.ts +++ /dev/null @@ -1,71 +0,0 @@ -export interface ConfigGetSuccess { - type: 'get' - server: { - host: string - port: number - debug: boolean - } - download: { - maxChunks: number - limitBytesPerSecond: number - saveFolder: string - retry: { - timeout: number - maxRetries: number - } - proxy: { - useProxy: boolean - proxyAddress: string - username: string - password: string - domain: string - } - } - job: { - maxConcurrentJobs: number - } - image: { - face: { - confidence: number - unionAll: boolean - maxDimension: number - } - saliency: { - paddingTop: number - paddingBottom: number - paddingLeft: number - paddingRight: number - } - } -} - -export interface ConfigUpdateSuccess { - type: 'update' - success: boolean - message: string -} - -export interface ConfigReloadSuccess { - type: 'reload' - success: boolean - message: string -} - -export interface ConfigResetSuccess { - type: 'reset' - success: boolean - message: string -} - -export interface ModelStatusSuccess { - type: 'modelstatus' - faceModelAvailable: boolean -} - -export interface ModelControlSuccess { - type: 'modelcontrol' - model: string - action: string - success: boolean - message?: string -} diff --git a/frontend/src/shared/services/backend/health/api.test.ts b/frontend/src/shared/services/backend/health/api.test.ts deleted file mode 100644 index c9f4808a..00000000 --- a/frontend/src/shared/services/backend/health/api.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { http, HttpResponse } from 'msw'; -import { server } from '../../../../../test/mocks/server'; -import { DEFAULT_BACKEND_URL } from '@/shared/services/signalr/constants'; -import { checkHealth } from './api'; - -describe('checkHealth', () => { - beforeEach(() => { - localStorage.clear(); - }); - - it('returns ok when backend is running', async () => { - await expect(checkHealth()).resolves.toEqual({ - status: 'ok', - message: 'Backend is running', - }); - }); - - it('returns unknown when backend reports not running', async () => { - server.use( - http.get(`${DEFAULT_BACKEND_URL}/health`, () => { - return HttpResponse.json({ IsRunning: false }); - }), - ); - - await expect(checkHealth()).resolves.toEqual({ - status: 'unknown', - message: 'Backend status unknown', - }); - }); - - it('throws when backend responds with error', async () => { - server.use( - http.get(`${DEFAULT_BACKEND_URL}/health`, () => { - return new HttpResponse(null, { status: 500 }); - }), - ); - - await expect(checkHealth()).rejects.toThrow('Backend server is not responding'); - }); -}); diff --git a/frontend/src/shared/services/backend/health/api.ts b/frontend/src/shared/services/backend/health/api.ts deleted file mode 100644 index ac7c0d8e..00000000 --- a/frontend/src/shared/services/backend/health/api.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { getBackendBaseUrl } from '../../signalrClient' - -/** - * Calls the backend health endpoint and normalizes the response. - */ -export async function checkHealth(): Promise<{ status: string; message: string }> { - const baseUrl = getBackendBaseUrl() - const response = await fetch(`${baseUrl}/health`) - - if (!response.ok) { - throw new Error('Backend server is not responding') - } - - const data = (await response.json()) as { IsRunning?: boolean } - return { - status: data.IsRunning ? 'ok' : 'unknown', - message: data.IsRunning ? 'Backend is running' : 'Backend status unknown', - } -} diff --git a/frontend/src/shared/services/backend/jobs/api.ts b/frontend/src/shared/services/backend/jobs/api.ts deleted file mode 100644 index fbbfba9d..00000000 --- a/frontend/src/shared/services/backend/jobs/api.ts +++ /dev/null @@ -1,493 +0,0 @@ -import { loggers } from '@/shared/services/logging'; -import { jobHub } from '../clients' -import type { ControlAction, ResponseBase, SlideImageConfig, SlideTextConfig } from '../common/types' -import { assertSuccess } from '../common/utils' -import { - mapJobStateToJobStatus, - normalizeShapeDto, - normalizeJobDetail, - normalizeJobSummary, - parseJobPayload, -} from './normalize' -import type { - GroupSummary, - JobStatusInfo, - SlideGlobalGetGroupsSuccess, - SlideGroupCreateSuccess, - SlideGroupRemoveSuccess, - SlideGroupStatusSuccess, - SlideJobLogsSuccess, - SlideJobRemoveSuccess, - SlideJobStatusSuccess, - SlideScanPlaceholdersSuccess, - SlideScanShapesSuccess, - SlideScanTemplateSuccess, - JobExportPayload, - JobDetail, - JobSummary, - JobType, -} from './types' - -/** - * Fetches detailed information for a specific job. - * - * @param jobId - The unique job identifier - * @param jobType - Type of job ('Group' or 'Sheet') - * @param includeSheets - Whether to include child sheet job details - * @param includePayload - Whether to include the job creation payload - * @returns Job detail object or null if not found - */ -async function fetchJobDetail( - jobId: string, - jobType?: JobType, - includeSheets = false, - includePayload = false, -): Promise { - if (!jobId) return null - const response = await jobHub.sendRequest({ - type: 'jobquery', - jobId: jobId, - jobType: jobType, - includeSheets, - includePayload, - }) - const data = assertSuccess(response) - const job = (data as Record).job as Record | undefined - if (!job) return null - return normalizeJobDetail(job) -} - -/** - * Fetches a list of jobs filtered by scope and type. - * - * @param scope - Filter by 'Active', 'Completed', or 'All' jobs - * @param jobType - Optional filter by job type - * @returns Array of job summary objects - */ -async function fetchJobList( - scope: 'Active' | 'Completed' | 'All', - jobType?: JobType, -): Promise { - const response = await jobHub.sendRequest({ - type: 'jobquery', - scope, - jobType: jobType, - }) - const data = assertSuccess(response) - const jobs = (((data as Record).jobs as Array>) ?? - []) as Array> - return jobs.map((job) => normalizeJobSummary(job)) -} - -/** - * Scans a PowerPoint template file for available shapes. - * - * @param filePath - Path to the PowerPoint template file - * @returns Shape information including shape IDs and types - */ -export async function scanShapes(filePath: string): Promise { - const response = await jobHub.sendRequest({ - type: 'scanshapes', - filePath, - }) - const raw = assertSuccess(response) as unknown as Record - return { - type: 'scanshapes', - filePath: (raw.filePath as string) ?? filePath, - shapes: ((raw.shapes ?? []) as Array>).map((shape) => - normalizeShapeDto(shape), - ), - } satisfies SlideScanShapesSuccess -} - -/** - * Scans a PowerPoint template for text placeholders. - * - * @param filePath - Path to the PowerPoint template file - * @returns List of placeholder names found in the template - */ -export async function scanPlaceholders(filePath: string): Promise { - const response = await jobHub.sendRequest({ - type: 'scanplaceholders', - filePath, - }) - const raw = assertSuccess(response) as unknown as Record< - string, - unknown - > - return { - type: 'scanplaceholders', - filePath: (raw.filePath as string) ?? filePath, - placeholders: (raw.placeholders ?? []) as string[], - } satisfies SlideScanPlaceholdersSuccess -} - -/** - * Scans a PowerPoint template for both shapes and placeholders. - * - * @param filePath - Path to the PowerPoint template file - * @returns Combined shape and placeholder information - */ -export async function scanTemplate(filePath: string): Promise { - const response = await jobHub.sendRequest({ - type: 'scantemplate', - filePath, - }) - const raw = assertSuccess(response) as unknown as Record< - string, - unknown - > - return { - type: 'scantemplate', - filePath: (raw.filePath as string) ?? filePath, - shapes: ((raw.shapes ?? []) as Array>).map((shape) => - normalizeShapeDto(shape), - ), - placeholders: (raw.placeholders ?? []) as string[], - } satisfies SlideScanTemplateSuccess -} - -/** - * Creates a new slide generation group job. - * - * @param request - Job creation parameters including template, spreadsheet, and output paths - * @returns Created group ID and associated sheet job IDs - */ -export async function createGroup( - request: Record, -): Promise { - const templatePath = request.templatePath as string - const spreadsheetPath = request.spreadsheetPath as string - const outputPath = request.outputPath as string - const sheetNames = request.sheetNames as string[] | undefined - const textConfigs = request.textConfigs as SlideTextConfig[] | undefined - const imageConfigs = request.imageConfigs as SlideImageConfig[] | undefined - - const response = await jobHub.sendRequest({ - type: 'jobcreate', - jobType: 'Group', - templatePath, - spreadsheetPath, - outputPath, - sheetNames, - textConfigs, - imageConfigs, - }) - const data = assertSuccess(response) - const dataRecord = data as Record - const job = normalizeJobSummary((dataRecord.job as Record) ?? {}) - const sheetJobIds = ((dataRecord.sheetJobIds as Record) ?? {}) as Record< - string, - string - > - return { - type: 'groupcreate', - groupId: job.jobId, - outputFolder: job.outputPath ?? '', - jobIds: sheetJobIds, - } satisfies SlideGroupCreateSuccess -} - -/** - * Gets detailed status for a group job including all child sheet jobs. - * - * @param request - Object containing groupId - * @returns Group status with progress and individual sheet job statuses - */ -export async function groupStatus( - request: Record, -): Promise { - const groupId = request.groupId as string - const detail = await fetchJobDetail(groupId, 'Group', true, true) - if (!detail) { - throw new Error(`Group job ${groupId} not found`) - } - - const sheets = detail.sheets ?? {} - const sheetEntries = Object.entries(sheets) - const sheetDetails = await Promise.all( - sheetEntries.map(async ([sheetId, summary]) => { - const sheetDetail = await fetchJobDetail(sheetId, 'Sheet').catch(() => null) - return { sheetId, summary, sheetDetail } - }), - ) - - const normalizedJobs: Record = {} - sheetDetails.forEach(({ sheetId, summary, sheetDetail }) => { - const status = mapJobStateToJobStatus(sheetDetail?.status ?? summary.status) - normalizedJobs[sheetId] = { - jobId: sheetId, - sheetName: summary.sheetName ?? sheetDetail?.sheetName ?? '', - status: status, - currentRow: sheetDetail?.currentRow ?? 0, - totalRows: sheetDetail?.totalRows ?? 0, - progress: summary.progress ?? sheetDetail?.progress ?? 0, - outputPath: sheetDetail?.outputPath ?? summary.outputPath, - errorMessage: sheetDetail?.errorMessage ?? undefined, - errorCount: summary.errorCount ?? sheetDetail?.errorCount ?? 0, - hangfireJobId: sheetDetail?.hangfireJobId ?? summary.hangfireJobId ?? undefined, - } - }) - - return { - type: 'groupstatus', - groupId: detail.jobId, - status: mapJobStateToJobStatus(detail.status), - progress: detail.progress ?? 0, - jobs: normalizedJobs, - errorCount: detail.errorCount ?? 0, - } satisfies SlideGroupStatusSuccess -} - -/** - * Sends a control action (pause, resume, cancel) to a group job. - * - * @param request - Object containing groupId and action - * @returns Response indicating success or failure - */ -export async function groupControl(request: Record): Promise { - const groupId = request.groupId as string - const action = request.action as ControlAction - - const response = await jobHub.sendRequest({ - type: 'jobcontrol', - jobId: groupId, - jobType: 'Group', - action, - }) - return assertSuccess(response) -} - -/** - * Removes a group job and all its associated sheet jobs. - * - * @param request - Object containing groupId - * @returns Confirmation of removal - */ -export async function removeGroup( - request: Record, -): Promise { - const groupId = request.groupId as string - if (groupId) { - await jobHub.sendRequest({ - type: 'jobcontrol', - jobId: groupId, - jobType: 'Group', - action: 'Remove', - }) - } - return { type: 'groupremove', groupId: groupId, removed: true } -} - -/** - * Gets detailed status for a single sheet job. - * - * @param request - Object containing jobId - * @returns Sheet job status including progress and error information - */ -export async function jobStatus(request: Record): Promise { - const jobId = request.jobId as string - const detail = await fetchJobDetail(jobId, 'Sheet') - if (!detail) { - throw new Error(`Sheet job ${jobId} not found`) - } - return { - type: 'jobstatus', - jobId: detail.jobId, - sheetName: detail.sheetName ?? '', - status: mapJobStateToJobStatus(detail.status), - currentRow: detail.currentRow ?? 0, - totalRows: detail.totalRows ?? 0, - progress: detail.progress ?? 0, - outputPath: detail.outputPath ?? undefined, - errorMessage: detail.errorMessage ?? undefined, - errorCount: detail.errorCount ?? undefined, - hangfireJobId: detail.hangfireJobId ?? undefined, - } satisfies SlideJobStatusSuccess -} - -/** - * Sends a control action (pause, resume, cancel) to a sheet job. - * - * @param request - Object containing jobId and action - * @returns Response indicating success or failure - */ -export async function jobControl(request: Record): Promise { - const jobId = request.jobId as string - const action = request.action as ControlAction - const response = await jobHub.sendRequest({ - type: 'jobcontrol', - jobId: jobId, - jobType: 'Sheet', - action, - }) - return assertSuccess(response) -} - -/** - * Removes a single sheet job. - * - * @param request - Object containing jobId - * @returns Confirmation of removal - */ -export async function removeJob(request: Record): Promise { - const jobId = request.jobId as string - if (jobId) { - await jobHub.sendRequest({ - type: 'jobcontrol', - jobId: jobId, - jobType: 'Sheet', - action: 'Remove', - }) - } - return { type: 'jobremove', jobId: jobId, removed: true } -} - -/** - * Retrieves logs for a specific job. - * - * @param request - Object containing jobId - * @returns Job logs array - */ -export async function getJobLogs(request: Record): Promise { - const jobId = request.jobId as string - return { - type: 'joblogs', - jobId: jobId, - logs: [], - } satisfies SlideJobLogsSuccess -} - -/** - * Retrieves the original creation payload for a group job. - * - * @param groupId - The group job identifier - * @returns The job export payload or null if not found - */ -export async function getGroupPayload(groupId: string): Promise { - if (!groupId) return null - try { - const detail = await fetchJobDetail(groupId, 'Group', false, true) - return parseJobPayload(detail?.payloadJson ?? null) - } catch { - return null - } -} - -/** - * Sends a control action to all active group jobs. - * - * @param request - Object containing action - * @returns Success indicator - */ -export async function globalControl(request: Record): Promise { - const action = request.action as ControlAction - const groups = await fetchJobList('Active', 'Group') - await Promise.all( - groups.map((group) => - jobHub.sendRequest({ - type: 'jobcontrol', - jobId: group.jobId, - jobType: 'Group', - action, - }), - ), - ) - return { ok: true } -} - -/** - * Retrieves all group jobs with their summary information. - * - * @returns List of all group summaries - */ -export async function getAllGroups(): Promise { - const groups = await fetchJobList('All', 'Group') - const summaries = await Promise.all( - groups.map(async (group) => { - let workbookPath = '' - let outputFolder = group.outputPath - let sheetCount = 0 - let completedSheets = 0 - try { - const detail = await fetchJobDetail(group.jobId, 'Group', true, true) - if (detail) { - outputFolder = detail.outputFolder ?? outputFolder - const sheets = detail.sheets ?? {} - sheetCount = Object.keys(sheets).length - completedSheets = Object.values(sheets).filter( - (sheet) => mapJobStateToJobStatus(sheet.status) === 'Completed', - ).length - const payload = parseJobPayload(detail.payloadJson) - workbookPath = payload?.spreadsheetPath ?? '' - } - } catch (error) { - loggers.jobs.warn('Failed to load job payload:', error) - } - - return { - groupId: group.jobId, - workbookPath: workbookPath, - outputFolder: outputFolder ?? '', - status: mapJobStateToJobStatus(group.status), - progress: group.progress ?? 0, - sheetCount: sheetCount, - completedSheets: completedSheets, - errorCount: group.errorCount ?? 0, - } satisfies GroupSummary - }), - ) - - return { - type: 'getallgroups', - groups: summaries, - } satisfies SlideGlobalGetGroupsSuccess -} - -/** - * Subscribes to real-time updates for a group job. - * - * @param groupId - The group job identifier to subscribe to - */ -export async function subscribeGroup(groupId: string): Promise { - await jobHub.invoke('SubscribeGroup', groupId) -} - -/** - * Subscribes to real-time updates for a sheet job. - * - * @param sheetId - The sheet job identifier to subscribe to - */ -export async function subscribeSheet(sheetId: string): Promise { - await jobHub.invoke('SubscribeSheet', sheetId) -} - -/** - * Registers a handler for slide notification events. - * - * @param handler - Callback function for notification payloads - * @returns Cleanup function to unsubscribe - */ -export function onSlideNotification(handler: (payload: unknown) => void): () => void { - return jobHub.onNotification(handler) -} - -/** - * Registers a handler for reconnection events. - * - * @param handler - Callback function invoked when connection is re-established - * @returns Cleanup function to unsubscribe - */ -export function onSlideReconnected(handler: (connectionId?: string) => void): () => void { - return jobHub.onReconnected(handler) -} - -/** - * Registers a handler for initial connection events. - * - * @param handler - Callback function invoked when connection is first established - * @returns Cleanup function to unsubscribe - */ -export function onSlideConnected(handler: (connectionId?: string) => void): () => void { - return jobHub.onConnected(handler) -} diff --git a/frontend/src/shared/services/backend/jobs/normalize.ts b/frontend/src/shared/services/backend/jobs/normalize.ts deleted file mode 100644 index 1cb3b571..00000000 --- a/frontend/src/shared/services/backend/jobs/normalize.ts +++ /dev/null @@ -1,102 +0,0 @@ -import type { ShapeDto } from '../common/types' -import type { JobDetail, JobExportPayload, JobState, JobSummary, JobType } from './types' - -export function normalizeShapeDto(input: Record): ShapeDto { - return { - id: ((input.id as number) ?? 0) as number, - name: typeof input.name === 'string' ? input.name : '', - data: typeof input.data === 'string' ? input.data : '', - kind: typeof input.kind === 'string' ? input.kind : undefined, - isImage: (input.isImage as boolean) ?? undefined, - } -} - -export function normalizeJobType(value: unknown): JobType { - const raw = typeof value === 'string' ? value.toLowerCase() : '' - return raw === 'sheet' ? 'Sheet' : 'Group' -} - -export function normalizeJobState(value: unknown): JobState { - const raw = typeof value === 'string' ? value.toLowerCase() : '' - switch (raw) { - case 'processing': - return 'Processing' - case 'paused': - return 'Paused' - case 'done': - return 'Done' - case 'cancelled': - return 'Cancelled' - case 'error': - return 'Error' - case 'pending': - default: - return 'Pending' - } -} - -export function mapJobStateToJobStatus(state: JobState): string { - switch (state) { - case 'Processing': - return 'Running' - case 'Done': - return 'Completed' - case 'Error': - return 'Failed' - default: - return state - } -} - -export function normalizeJobSummary(input: Record): JobSummary { - return { - jobId: ((input.jobId as string) ?? '') as string, - jobType: normalizeJobType(input.jobType), - status: normalizeJobState(input.status), - progress: ((input.progress as number) ?? 0) as number, - groupId: typeof input.groupId === 'string' ? input.groupId : undefined, - sheetName: typeof input.sheetName === 'string' ? input.sheetName : undefined, - outputPath: typeof input.outputPath === 'string' ? input.outputPath : undefined, - errorCount: typeof input.errorCount === 'number' ? input.errorCount : undefined, - hangfireJobId: typeof input.hangfireJobId === 'string' ? input.hangfireJobId : undefined, - } -} - -export function normalizeJobDetail(input: Record): JobDetail { - const summary = normalizeJobSummary(input) - const sheetsRaw = ((input.sheets as Record>) ?? {}) as Record< - string, - Record - > - const sheets: Record = {} - Object.entries(sheetsRaw).forEach(([sheetId, sheet]) => { - sheets[sheetId] = normalizeJobSummary({ jobId: sheetId, ...sheet }) - }) - - return { - ...summary, - errorMessage: - typeof input.errorMessage === 'string' || input.errorMessage === null - ? (input.errorMessage as string | null | undefined) - : undefined, - currentRow: (input.currentRow as number) ?? undefined, - totalRows: (input.totalRows as number) ?? undefined, - outputFolder: typeof input.outputFolder === 'string' ? input.outputFolder : undefined, - sheets: Object.keys(sheets).length > 0 ? sheets : undefined, - payloadJson: - typeof input.payloadJson === 'string' || input.payloadJson === null - ? (input.payloadJson as string | null | undefined) - : undefined, - } -} - -export function parseJobPayload(payloadJson?: string | null): JobExportPayload | null { - if (!payloadJson) return null - try { - const payload = JSON.parse(payloadJson) as JobExportPayload - if (!payload || typeof payload !== 'object') return null - return payload - } catch { - return null - } -} diff --git a/frontend/src/shared/services/backend/jobs/types.ts b/frontend/src/shared/services/backend/jobs/types.ts deleted file mode 100644 index aae4b32d..00000000 --- a/frontend/src/shared/services/backend/jobs/types.ts +++ /dev/null @@ -1,139 +0,0 @@ -import type { ShapeDto, SlideImageConfig, SlideTextConfig } from '../common/types' - -export type JobType = 'Group' | 'Sheet' -export type JobState = 'Pending' | 'Processing' | 'Paused' | 'Done' | 'Cancelled' | 'Error' - -export interface JobSummary { - jobId: string - jobType: JobType - status: JobState - progress: number - groupId?: string - sheetName?: string - outputPath?: string - errorCount?: number - hangfireJobId?: string -} - -export interface JobDetail extends JobSummary { - errorMessage?: string | null - currentRow?: number - totalRows?: number - outputFolder?: string - sheets?: Record - payloadJson?: string | null -} - -export interface JobExportPayload { - jobType: JobType - templatePath: string - spreadsheetPath: string - outputPath: string - sheetNames?: string[] - sheetName?: string - textConfigs?: SlideTextConfig[] - imageConfigs?: SlideImageConfig[] -} - -export interface SlideScanShapesSuccess { - type: 'scanshapes' - filePath: string - shapes: ShapeDto[] -} - -export interface SlideScanPlaceholdersSuccess { - type: 'scanplaceholders' - filePath: string - placeholders: string[] -} - -export interface SlideScanTemplateSuccess { - type: 'scantemplate' - filePath: string - shapes: ShapeDto[] - placeholders: string[] -} - -export interface SlideGroupCreateSuccess { - type: 'groupcreate' - groupId: string - outputFolder: string - jobIds: Record -} - -export interface JobStatusInfo { - jobId: string - sheetName: string - status: string - currentRow: number - totalRows: number - progress: number - outputPath?: string - errorMessage?: string | null - errorCount?: number - hangfireJobId?: string -} - -export interface SlideGroupStatusSuccess { - type: 'groupstatus' - groupId: string - status: string - progress: number - jobs: Record - errorCount?: number -} - -export interface SlideGroupRemoveSuccess { - type: 'groupremove' - groupId: string - removed: boolean -} - -export interface SlideJobStatusSuccess { - type: 'jobstatus' - jobId: string - sheetName: string - status: string - currentRow: number - totalRows: number - progress: number - outputPath?: string - errorMessage?: string | null - errorCount?: number - hangfireJobId?: string -} - -export interface SlideJobRemoveSuccess { - type: 'jobremove' - jobId: string - removed: boolean -} - -export interface JobLogEntry { - level: string - message: string - timestamp: string - data?: Record -} - -export interface SlideJobLogsSuccess { - type: 'joblogs' - jobId: string - logs: JobLogEntry[] -} - -export interface GroupSummary { - groupId: string - workbookPath: string - outputFolder?: string - status: string - progress: number - sheetCount: number - completedSheets: number - errorCount?: number -} - -export interface SlideGlobalGetGroupsSuccess { - type: 'getallgroups' - groups: GroupSummary[] -} diff --git a/frontend/src/shared/services/backend/sheets/api.ts b/frontend/src/shared/services/backend/sheets/api.ts deleted file mode 100644 index da3eb511..00000000 --- a/frontend/src/shared/services/backend/sheets/api.ts +++ /dev/null @@ -1,337 +0,0 @@ -import { loggers } from '@/shared/services/logging'; -import { sheetHub } from '../clients' -import type { ResponseBase } from '../common/types' -import { assertSuccess } from '../common/utils' -import type { - ColumnListResponse, - FileListResponse, - LoadFileResponse, - SheetDataResponse, - SheetDetailInfo, - SheetInfo, - SheetListResponse, - SheetWorkbookGetInfoSuccess, -} from './types' - -/** Response type for opening a workbook file */ -interface OpenBookSheetSuccess { - Type: 'openfile' - FilePath: string -} - -/** Response type for closing a workbook file */ -interface SheetWorkbookCloseSuccess { - Type: 'closefile' - FilePath: string -} - -/** Response type for getting sheet table information */ -interface SheetWorkbookGetSheetInfoSuccess { - Type: 'gettables' - FilePath: string - Sheets: Record -} - -/** Response type for getting sheet column headers */ -interface SheetWorksheetGetHeadersSuccess { - Type: 'getheaders' - FilePath: string - SheetName: string - Headers: Array -} - -/** Response type for getting a single row */ -interface SheetWorksheetGetRowSuccess { - Type: 'getrow' - FilePath: string - TableName: string - RowNumber: number - Row: Record -} - -/** - * Opens and loads an Excel/spreadsheet file for processing. - * - * @param filePath - Path to the spreadsheet file - * @returns File load result with sheet information - */ -export async function loadFile(filePath: string): Promise { - const open = await sheetHub.sendRequest({ - type: 'openfile', - filePath, - }) - assertSuccess(open) - - const info = await sheetHub.sendRequest({ - type: 'gettables', - filePath, - }) - const tables = assertSuccess(info) - const sheets = tables.Sheets ?? {} - const sheetNames = Object.keys(sheets) - - return { - success: true, - group_id: filePath, - file_type: 'sheet', - num_sheets: sheetNames.length, - sheets: sheetNames, - } -} - -/** - * Closes and unloads a previously loaded spreadsheet file. - * - * @param filePath - Path to the spreadsheet file - * @returns Success indicator - */ -export async function unloadFile(filePath: string): Promise<{ success: boolean }> { - const response = await sheetHub.sendRequest({ - type: 'closefile', - filePath, - }) - assertSuccess(response) - return { success: true } -} - -/** - * Gets the list of currently loaded files. - * - * @returns List of loaded file information - */ -export async function getLoadedFiles(): Promise { - return { files: [] } -} - -/** - * Gets all sheets in a workbook file. - * - * @param filePath - Path to the workbook file - * @returns List of sheets with metadata - */ -export async function getSheets(filePath: string): Promise { - const response = await sheetHub.sendRequest({ - type: 'gettables', - filePath, - }) - const raw = assertSuccess(response) as unknown as Record< - string, - unknown - > - - const tables = ((raw.sheets as Record) ?? {}) as Record - const sheets: SheetInfo[] = Object.entries(tables).map(([name, rows]) => ({ - sheet_id: name, - sheet_name: name, - num_rows: rows, - num_cols: 0, - })) - - return { sheets } -} - -/** - * Gets column headers for a specific sheet. - * - * @param filePath - Path to the workbook file - * @param sheetName - Name of the sheet - * @returns List of column names - */ -export async function getColumns(filePath: string, sheetName: string): Promise { - const response = await sheetHub.sendRequest({ - type: 'getheaders', - filePath, - sheetName, - }) - const raw = assertSuccess(response) as unknown as Record< - string, - unknown - > - const headers = ((raw.headers as Array) ?? []) as Array - const columns = headers.filter((header): header is string => Boolean(header)) - return { columns } -} - -/** - * Gets detailed information about a specific sheet. - * - * @param filePath - Path to the workbook file - * @param sheetName - Name of the sheet - * @returns Sheet details including row/column counts and headers - */ -export async function getSheetInfo(filePath: string, sheetName: string): Promise { - const tableResponse = await sheetHub.sendRequest({ - type: 'gettables', - filePath, - }) - const tablesRaw = assertSuccess( - tableResponse, - ) as unknown as Record - const tableMap = ((tablesRaw.sheets as Record) ?? {}) as Record - - const headerResponse = await sheetHub.sendRequest({ - type: 'getheaders', - filePath, - sheetName, - }) - const headersRaw = assertSuccess( - headerResponse, - ) as unknown as Record - const headerList = ((headersRaw.headers as Array) ?? []) as Array - const columns = headerList.filter((header): header is string => Boolean(header)) - - return { - sheet_id: sheetName, - sheet_name: sheetName, - num_rows: tableMap[sheetName] ?? 0, - num_cols: columns.length, - columns, - start_row: 1, - start_col: 1, - } -} - -/** - * Gets paginated row data from a sheet. - * - * @param filePath - Path to the workbook file - * @param sheetName - Name of the sheet - * @param offset - Row offset to start from (0-based) - * @param limit - Maximum number of rows to return - * @returns Sheet data with row content - */ -export async function getSheetData( - filePath: string, - sheetName: string, - offset = 0, - limit?: number, -): Promise { - const info = await getSheetInfo(filePath, sheetName) - const totalRows = info.num_rows - const startRow = offset + 1 - const endRow = limit ? Math.min(totalRows, offset + limit) : totalRows - - const data: Record[] = [] - for (let rowIndex = startRow; rowIndex <= endRow; rowIndex += 1) { - const response = await sheetHub.sendRequest({ - type: 'getrow', - filePath, - tableName: sheetName, - rowNumber: rowIndex, - }) - const rowRaw = assertSuccess(response) as unknown as Record< - string, - unknown - > - const rowData = ((rowRaw.row as Record) ?? {}) as Record< - string, - string | null - > - data.push(rowData) - } - - return { - columns: info.columns, - data, - num_rows: data.length, - offset, - total_rows: totalRows, - } -} - -/** - * Gets a single row from a sheet by index. - * - * @param filePath - Path to the workbook file - * @param sheetName - Name of the sheet - * @param rowIndex - 1-based row index - * @returns Row data as key-value pairs - */ -export async function getSheetRow( - filePath: string, - sheetName: string, - rowIndex: number, -): Promise<{ row_index: number; data: Record }> { - const response = await sheetHub.sendRequest({ - type: 'getrow', - filePath, - tableName: sheetName, - rowNumber: rowIndex, - }) - const rowRaw = assertSuccess(response) as unknown as Record< - string, - unknown - > - const rowData = ((rowRaw.row as Record) ?? {}) as Record< - string, - string | null - > - const rowNumber = ((rowRaw.rowNumber as number) ?? rowIndex) as number - return { row_index: rowNumber, data: rowData } -} - -/** - * Gets all unique column headers from multiple workbook files. - * - * @param filePaths - Array of workbook file paths - * @returns Sorted array of unique column names - */ -export async function getAllColumns(filePaths: string[]): Promise { - const allColumns = new Set() - - for (const filePath of filePaths) { - try { - const infoResponse = await sheetHub.sendRequest({ - type: 'getworkbookinfo', - filePath, - }) - const infoRaw = assertSuccess( - infoResponse, - ) as unknown as Record - const sheets = ((infoRaw.sheets as Array>) ?? []) as Array< - Record - > - sheets.forEach((sheet) => { - const headers = ((sheet.headers as Array) ?? []) as Array - headers - .filter((header): header is string => Boolean(header)) - .forEach((header) => allColumns.add(header)) - }) - } catch (error) { - loggers.jobs.error(`Error getting columns for file ${filePath}:`, error) - } - } - - return Array.from(allColumns).sort() -} - -/** - * Gets comprehensive workbook information including all sheets and their headers. - * - * @param filePath - Path to the workbook file - * @returns Workbook info with sheet details - */ -export async function getWorkbookInfo(filePath: string): Promise { - const response = await sheetHub.sendRequest({ - type: 'getworkbookinfo', - filePath, - }) - const raw = assertSuccess(response) as unknown as Record< - string, - unknown - > - const sheets = ((raw.sheets as Array>) ?? []) as Array< - Record - > - - return { - Type: 'getworkbookinfo', - FilePath: (raw.filePath as string) ?? filePath, - WorkbookName: (raw.workbookName as string) ?? undefined, - Sheets: sheets.map((sheet) => ({ - Name: ((sheet.name as string) ?? '') as string, - Headers: ((sheet.headers as Array) ?? []) as Array, - RowCount: ((sheet.rowCount as number) ?? 0) as number, - })), - } -} diff --git a/frontend/src/shared/services/backend/sheets/types.ts b/frontend/src/shared/services/backend/sheets/types.ts deleted file mode 100644 index 6a2b3ff3..00000000 --- a/frontend/src/shared/services/backend/sheets/types.ts +++ /dev/null @@ -1,62 +0,0 @@ -export interface SheetWorkbookGetInfoSuccess { - Type: 'getworkbookinfo' - FilePath: string - WorkbookName?: string - Sheets: Array<{ - Name: string - Headers: Array - RowCount: number - }> -} - -export interface LoadedFile { - group_id: string - file_path: string - file_type: string - num_sheets: number -} - -export interface SheetInfo { - sheet_id: string - sheet_name: string - num_rows: number - num_cols: number -} - -export interface LoadFileResponse { - success: boolean - group_id: string - file_type: string - num_sheets: number - sheets: string[] -} - -export interface FileListResponse { - files: LoadedFile[] -} - -export interface SheetListResponse { - sheets: SheetInfo[] -} - -export interface ColumnListResponse { - columns: string[] -} - -export interface SheetDetailInfo { - sheet_id: string - sheet_name: string - num_rows: number - num_cols: number - columns: string[] - start_row: number - start_col: number -} - -export interface SheetDataResponse { - columns: string[] - data: Record[] - num_rows: number - offset: number - total_rows: number -} diff --git a/frontend/src/shared/services/backendApi.ts b/frontend/src/shared/services/backendApi.ts deleted file mode 100644 index 9dda6df6..00000000 --- a/frontend/src/shared/services/backendApi.ts +++ /dev/null @@ -1,111 +0,0 @@ -/** - * Backend API types and functions barrel export. - * - * @module backendApi - * @remarks - * This module re-exports all backend API types and functions from their - * respective domain modules (jobs, sheets, config, health). - */ - -// Common types -export type { - ControlAction, - ShapeDto, - SlideImageConfig, - SlideTextConfig, -} from './backend/common/types'; - -// Job management types -export type { - GroupSummary, - JobDetail, - JobExportPayload, - JobLogEntry, - JobState, - JobStatusInfo, - JobSummary, - JobType, - SlideGlobalGetGroupsSuccess, - SlideGroupCreateSuccess, - SlideGroupRemoveSuccess, - SlideGroupStatusSuccess, - SlideJobLogsSuccess, - SlideJobRemoveSuccess, - SlideJobStatusSuccess, - SlideScanPlaceholdersSuccess, - SlideScanShapesSuccess, - SlideScanTemplateSuccess, -} from './backend/jobs/types'; - -// Configuration types -export type { - ConfigGetSuccess, - ConfigReloadSuccess, - ConfigResetSuccess, - ConfigUpdateSuccess, - ModelStatusSuccess, - ModelControlSuccess, -} from './backend/config/types'; - -// Sheet/workbook types -export type { - ColumnListResponse, - FileListResponse, - LoadFileResponse, - LoadedFile, - SheetDataResponse, - SheetDetailInfo, - SheetInfo, - SheetListResponse, - SheetWorkbookGetInfoSuccess, -} from './backend/sheets/types'; - -// Job management APIs -export { - createGroup, - getAllGroups, - getGroupPayload, - getJobLogs, - globalControl, - groupControl, - groupStatus, - jobControl, - jobStatus, - onSlideConnected, - onSlideNotification, - onSlideReconnected, - removeGroup, - removeJob, - scanPlaceholders, - scanShapes, - scanTemplate, - subscribeGroup, - subscribeSheet, -} from './backend/jobs/api'; - -// Sheet/workbook APIs -export { - getAllColumns, - getColumns, - getLoadedFiles, - getSheetData, - getSheetInfo, - getSheetRow, - getSheets, - getWorkbookInfo, - loadFile, - unloadFile, -} from './backend/sheets/api'; - -// Health check API -export { checkHealth } from './backend/health/api'; - -// Configuration APIs -export { - getConfig, - reloadConfig, - resetConfig, - updateConfig, - getModelStatus, - controlModel, -} from './backend/config/api'; diff --git a/frontend/src/shared/services/logging/index.ts b/frontend/src/shared/services/logging/index.ts deleted file mode 100644 index 73639e37..00000000 --- a/frontend/src/shared/services/logging/index.ts +++ /dev/null @@ -1,68 +0,0 @@ -/** - * Frontend logging service that sends logs to main process via IPC. - * Log format matches backend: [timestamp] [LEVEL] [Source] message - */ - -type LogLevel = 'debug' | 'info' | 'warn' | 'error'; - -interface LoggerOptions { - source: string; -} - -interface Logger { - debug: (message: string, ...args: unknown[]) => void; - info: (message: string, ...args: unknown[]) => void; - warn: (message: string, ...args: unknown[]) => void; - error: (message: string, ...args: unknown[]) => void; -} - -const formatMessage = (message: string, args: unknown[]): string => { - if (args.length === 0) return message; - // Simple template replacement for objects - const formatted = args - .map((arg) => (typeof arg === 'object' ? JSON.stringify(arg) : String(arg))) - .join(' '); - return `${message} ${formatted}`; -}; - -const log = (level: LogLevel, source: string, message: string, args: unknown[]): void => { - const formattedMessage = formatMessage(message, args); - - // Send to main process for file logging - if (window.electronAPI?.logRenderer) { - window.electronAPI.logRenderer(level, formattedMessage, source); - } - - // Also log to console for development - const consoleMethod = level === 'debug' ? 'log' : level; - // eslint-disable-next-line no-console - console[consoleMethod](`[${source}]`, message, ...args); -}; - -/** - * Creates a logger instance with a specific source context. - * - * @example - * const logger = createLogger({ source: 'SignalR' }); - * logger.info('Connected to hub'); - * logger.error('Connection failed', error); - */ -export const createLogger = (options: LoggerOptions): Logger => { - const { source } = options; - - return { - debug: (message: string, ...args: unknown[]) => log('debug', source, message, args), - info: (message: string, ...args: unknown[]) => log('info', source, message, args), - warn: (message: string, ...args: unknown[]) => log('warn', source, message, args), - error: (message: string, ...args: unknown[]) => log('error', source, message, args), - }; -}; - -// Pre-configured loggers for common modules -export const loggers = { - signalR: createLogger({ source: 'SignalR' }), - settings: createLogger({ source: 'Settings' }), - jobs: createLogger({ source: 'Jobs' }), - config: createLogger({ source: 'Config' }), - app: createLogger({ source: 'App' }), -}; diff --git a/frontend/src/shared/services/signalr/baseUrl.test.ts b/frontend/src/shared/services/signalr/baseUrl.test.ts deleted file mode 100644 index 5e176b5e..00000000 --- a/frontend/src/shared/services/signalr/baseUrl.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { - BACKEND_URL_KEY, - DEFAULT_BACKEND_URL, - PENDING_BACKEND_URL_KEY, - PENDING_BACKEND_URL_SESSION_KEY, -} from '@/shared/services/signalr/constants'; -import { getBackendBaseUrl, normalizeBaseUrl } from './baseUrl'; - -describe('normalizeBaseUrl', () => { - it('normalizes empty inputs', () => { - expect(normalizeBaseUrl('')).toBe(''); - expect(normalizeBaseUrl(' ')).toBe(''); - }); - - it('adds scheme and normalizes localhost', () => { - expect(normalizeBaseUrl('localhost:65500/')).toBe('http://127.0.0.1:65500'); - }); - - it('keeps scheme and removes trailing slash', () => { - expect(normalizeBaseUrl('https://example.com/')).toBe('https://example.com'); - }); -}); - -describe('getBackendBaseUrl', () => { - beforeEach(() => { - localStorage.clear(); - sessionStorage.clear(); - }); - - it('returns default when no stored urls exist', () => { - expect(getBackendBaseUrl()).toBe(normalizeBaseUrl(DEFAULT_BACKEND_URL)); - }); - - it('promotes pending url when allowed', () => { - localStorage.setItem(PENDING_BACKEND_URL_KEY, 'localhost:65000/'); - const value = getBackendBaseUrl(); - expect(value).toBe('http://127.0.0.1:65000'); - expect(localStorage.getItem(PENDING_BACKEND_URL_KEY)).toBeNull(); - expect(localStorage.getItem(BACKEND_URL_KEY)).toBe('http://127.0.0.1:65000'); - }); - - it('keeps pending url when session defers promotion', () => { - localStorage.setItem(PENDING_BACKEND_URL_KEY, 'localhost:65000/'); - localStorage.setItem(BACKEND_URL_KEY, 'http://stored:65001'); - sessionStorage.setItem(PENDING_BACKEND_URL_SESSION_KEY, '1'); - - const value = getBackendBaseUrl(); - expect(value).toBe('http://stored:65001'); - expect(localStorage.getItem(PENDING_BACKEND_URL_KEY)).toBe('localhost:65000/'); - }); -}); diff --git a/frontend/src/shared/services/signalr/baseUrl.ts b/frontend/src/shared/services/signalr/baseUrl.ts deleted file mode 100644 index 44dc1eba..00000000 --- a/frontend/src/shared/services/signalr/baseUrl.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { - BACKEND_URL_KEY, - DEFAULT_BACKEND_URL, - PENDING_BACKEND_URL_KEY, - PENDING_BACKEND_URL_SESSION_KEY, -} from './constants'; - -/** - * Normalizes a backend URL by applying the following transformations: - * - Trims whitespace - * - Adds `http://` scheme if missing - * - Converts `localhost` to `127.0.0.1` for consistency - * - Removes trailing slash - * - * @param url - The raw URL string to normalize - * @returns The normalized URL, or empty string if input is empty/whitespace - * - * @example - * normalizeBaseUrl('localhost:8080/') // => 'http://127.0.0.1:8080' - * normalizeBaseUrl('https://api.example.com') // => 'https://api.example.com' - */ -export function normalizeBaseUrl(url: string): string { - const trimmed = url.trim(); - if (!trimmed) return ''; - - const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`; - - const normalizedHost = withScheme.replace( - /^(https?:\/\/)localhost(?=[:/]|$)/i, - (_, scheme: string) => `${scheme}127.0.0.1`, - ); - - return normalizedHost.endsWith('/') ? normalizedHost.slice(0, -1) : normalizedHost; -} - -/** - * Resolves the backend base URL for SignalR connection. - * - * This function implements a "pending URL" mechanism that allows URL changes - * to take effect only once per browser session, preventing connection issues - * during active sessions. - * - * @returns The normalized backend base URL to use for connections - * - * @remarks - * URL resolution priority: - * 1. If a pending URL exists and hasn't been promoted this session, it becomes active - * 2. Otherwise, uses the stored URL from localStorage - * 3. Falls back to DEFAULT_BACKEND_URL if no URL is configured - */ -export function getBackendBaseUrl(): string { - const pending = localStorage.getItem(PENDING_BACKEND_URL_KEY) ?? ''; - const canPromote = - typeof sessionStorage === 'undefined' || - !sessionStorage.getItem(PENDING_BACKEND_URL_SESSION_KEY); - - if (pending && canPromote) { - const normalizedPending = normalizeBaseUrl(pending); - if (normalizedPending) { - localStorage.setItem(BACKEND_URL_KEY, normalizedPending); - } - localStorage.removeItem(PENDING_BACKEND_URL_KEY); - } - - const stored = localStorage.getItem(BACKEND_URL_KEY) ?? ''; - const normalized = normalizeBaseUrl(stored); - return normalized || normalizeBaseUrl(DEFAULT_BACKEND_URL); -} diff --git a/frontend/src/shared/services/signalr/client.ts b/frontend/src/shared/services/signalr/client.ts deleted file mode 100644 index e48bc9b4..00000000 --- a/frontend/src/shared/services/signalr/client.ts +++ /dev/null @@ -1,307 +0,0 @@ -import { - HubConnection, - HubConnectionBuilder, - HubConnectionState, - HttpTransportType, - LogLevel, -} from '@microsoft/signalr'; - -import { loggers } from '../logging'; -import { getBackendBaseUrl } from './baseUrl'; -import { NOTIFICATION_METHOD, RESPONSE_METHOD } from './constants'; - -/** - * A SignalR hub client wrapper that provides automatic connection management, - * request queuing, and reconnection handling. - * - * @remarks - * This client wraps the Microsoft SignalR HubConnection and adds: - * - Automatic connection establishment before sending requests - * - Request queuing to prevent concurrent connection attempts - * - Automatic reconnection when backend URL changes - * - Event handlers for notifications and connection state changes - * - * @example - * ```typescript - * const client = new SignalRHubClient('/hubs/slides'); - * - * // Subscribe to notifications - * const unsubscribe = client.onNotification((payload) => { - * console.log('Received:', payload); - * }); - * - * // Send a request - * const result = await client.sendRequest({ action: 'generate' }); - * - * // Cleanup - * unsubscribe(); - * await client.dispose(); - * ``` - */ -export class SignalRHubClient { - /** The hub endpoint path (e.g., '/hubs/slides') */ - private readonly hubPath: string; - /** The underlying SignalR connection */ - private connection: HubConnection; - /** Current backend base URL */ - private baseUrl: string; - /** Set of notification handlers to invoke on ReceiveNotification events */ - private notificationHandlers = new Set<(payload: unknown) => void>(); - /** Set of handlers to invoke when connection is re-established after disconnect */ - private reconnectHandlers = new Set<(connectionId?: string) => void>(); - /** Set of handlers to invoke when initial connection is established */ - private connectedHandlers = new Set<(connectionId?: string) => void>(); - /** Queue to serialize connection and request operations */ - private queue: Promise = Promise.resolve(); - - /** - * Creates a new SignalR hub client. - * - * @param hubPath - The hub endpoint path (e.g., '/hubs/slides') - */ - constructor(hubPath: string) { - this.hubPath = hubPath; - this.baseUrl = getBackendBaseUrl(); - this.connection = this.buildConnection(this.baseUrl); - } - - /** - * Sends a request to the backend hub and waits for a typed response. - * - * @typeParam TResponse - The expected response type - * @param payload - The request payload to send - * @param timeoutMs - Maximum time to wait for response (default: 15000ms) - * @returns Promise resolving to the backend response - * @throws Error if timeout expires or connection fails - */ - async sendRequest( - payload: Record, - timeoutMs = 15000, - ): Promise { - return this.enqueue(async () => { - await this.ensureConnected(); - return await this.sendRequestInternal(payload, timeoutMs); - }); - } - - /** - * Invokes a hub method without waiting for a specific response. - * - * @param methodName - The hub method name to invoke - * @param args - Arguments to pass to the hub method - */ - async invoke(methodName: string, ...args: unknown[]): Promise { - await this.enqueue(async () => { - await this.ensureConnected(); - await this.connection.invoke(methodName, ...args); - }); - } - - /** - * Registers a handler for backend notification events. - * - * @param handler - Callback function invoked when a notification is received - * @returns Cleanup function to unsubscribe the handler - */ - onNotification(handler: (payload: unknown) => void): () => void { - this.notificationHandlers.add(handler); - this.connection.on(NOTIFICATION_METHOD, handler); - return () => { - this.notificationHandlers.delete(handler); - this.connection.off(NOTIFICATION_METHOD, handler); - }; - } - - /** - * Registers a handler for reconnection events. - * - * @param handler - Callback function invoked when connection is re-established - * @returns Cleanup function to unsubscribe the handler - */ - onReconnected(handler: (connectionId?: string) => void): () => void { - this.reconnectHandlers.add(handler); - return () => { - this.reconnectHandlers.delete(handler); - }; - } - - /** - * Registers a handler for initial connection events. - * - * @param handler - Callback function invoked when connection is first established - * @returns Cleanup function to unsubscribe the handler - */ - onConnected(handler: (connectionId?: string) => void): () => void { - this.connectedHandlers.add(handler); - return () => { - this.connectedHandlers.delete(handler); - }; - } - - /** - * Builds a new SignalR HubConnection with WebSocket transport. - * - * @param baseUrl - The backend base URL - * @returns Configured HubConnection instance - */ - private buildConnection(baseUrl: string): HubConnection { - const connection = new HubConnectionBuilder() - .withUrl(`${baseUrl}${this.hubPath}`, { - withCredentials: false, - skipNegotiation: true, - transport: HttpTransportType.WebSockets, - }) - .withAutomaticReconnect() - .configureLogging(LogLevel.Warning) - .build(); - - connection.onreconnected((connectionId) => { - this.reconnectHandlers.forEach((handler) => handler(connectionId ?? undefined)); - }); - - return connection; - } - - /** - * Refreshes the connection if the backend URL has changed. - * Stops the old connection and creates a new one with updated URL. - */ - private async refreshConnectionIfNeeded(): Promise { - const currentBaseUrl = getBackendBaseUrl(); - if (currentBaseUrl === this.baseUrl) return; - - this.baseUrl = currentBaseUrl; - const previous = this.connection; - if (previous.state !== HubConnectionState.Disconnected) { - try { - await previous.stop(); - } catch (error) { - loggers.signalR.warn('Failed to stop connection before reconnect:', error); - } - } - - this.connection = this.buildConnection(this.baseUrl); - this.notificationHandlers.forEach((handler) => { - this.connection.on(NOTIFICATION_METHOD, handler); - }); - } - - /** - * Ensures the connection is in Connected state before operations. - * Handles reconnection and waits for pending connection attempts. - */ - private async ensureConnected(): Promise { - await this.refreshConnectionIfNeeded(); - const state = this.connection.state; - if (state === HubConnectionState.Connected) return; - - if (state === HubConnectionState.Connecting || state === HubConnectionState.Reconnecting) { - await this.waitForConnected(); - return; - } - - await this.connection.start(); - this.connectedHandlers.forEach((handler) => handler(this.connection.connectionId ?? undefined)); - } - - /** - * Waits for a pending connection to complete with polling. - * - * @returns Promise that resolves when connected or rejects if disconnected - */ - private waitForConnected(): Promise { - return new Promise((resolve, reject) => { - const check = () => { - if (this.connection.state === HubConnectionState.Connected) { - resolve(); - return; - } - if (this.connection.state === HubConnectionState.Disconnected) { - reject(new Error('SignalR disconnected while connecting.')); - return; - } - setTimeout(check, 50); - }; - check(); - }); - } - - /** - * Enqueues an async operation to prevent concurrent connection operations. - * - * @typeParam T - The return type of the work function - * @param work - The async function to execute - * @returns Promise resolving to the work function result - */ - private enqueue(work: () => Promise): Promise { - const result = this.queue.then(work, work); - this.queue = result.then( - () => undefined, - () => undefined, - ); - return result; - } - - /** - * Internal method that sends a request and waits for a response with timeout. - * - * @typeParam TResponse - The expected response type - * @param payload - The request payload - * @param timeoutMs - Maximum time to wait for response - * @returns Promise resolving to the backend response - */ - private sendRequestInternal( - payload: Record, - timeoutMs: number, - ): Promise { - return new Promise((resolve, reject) => { - let isCleanedUp = false; - - const timeoutId = setTimeout(() => { - if (!isCleanedUp) { - cleanup(); - reject(new Error('Timeout waiting for backend response.')); - } - }, timeoutMs); - - const handleResponse = (response: TResponse) => { - if (!isCleanedUp) { - cleanup(); - resolve(response); - } - }; - - const cleanup = () => { - if (isCleanedUp) return; - isCleanedUp = true; - clearTimeout(timeoutId); - this.connection.off(RESPONSE_METHOD, handleResponse); - }; - - this.connection.on(RESPONSE_METHOD, handleResponse); - this.connection.invoke('ProcessRequest', payload).catch((error) => { - if (!isCleanedUp) { - cleanup(); - reject(error); - } - }); - }); - } - - /** - * Dispose of the SignalR client and clean up resources - */ - async dispose(): Promise { - this.notificationHandlers.clear(); - this.reconnectHandlers.clear(); - this.connectedHandlers.clear(); - - if (this.connection.state !== HubConnectionState.Disconnected) { - try { - await this.connection.stop(); - } catch (error) { - loggers.signalR.warn('Error stopping connection during dispose:', error); - } - } - } -} diff --git a/frontend/src/shared/services/signalr/constants.ts b/frontend/src/shared/services/signalr/constants.ts deleted file mode 100644 index df6b513b..00000000 --- a/frontend/src/shared/services/signalr/constants.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Default backend URL for SignalR connection when no custom URL is configured. - */ -export const DEFAULT_BACKEND_URL = 'http://127.0.0.1:65500'; - -/** - * LocalStorage key for storing the active backend URL. - */ -export const BACKEND_URL_KEY = 'slidegen.backend.url'; - -/** - * LocalStorage key for storing a pending backend URL to be applied on next session. - */ -export const PENDING_BACKEND_URL_KEY = 'slidegen.backend.url.pending'; - -/** - * SessionStorage key to track whether a pending URL has been promoted this session. - */ -export const PENDING_BACKEND_URL_SESSION_KEY = 'slidegen.backend.url.pending.defer'; - -/** - * SignalR hub method name for receiving responses from the backend. - */ -export const RESPONSE_METHOD = 'ReceiveResponse'; - -/** - * SignalR hub method name for receiving real-time notifications from the backend. - */ -export const NOTIFICATION_METHOD = 'ReceiveNotification'; diff --git a/frontend/src/shared/services/signalrClient.ts b/frontend/src/shared/services/signalrClient.ts deleted file mode 100644 index e9968daa..00000000 --- a/frontend/src/shared/services/signalrClient.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Re-exports for SignalR client functionality. - * - * @module signalrClient - * @remarks - * This module provides the core SignalR client and URL utilities for - * connecting to the backend. - */ -export { getBackendBaseUrl, normalizeBaseUrl } from './signalr/baseUrl'; -export { SignalRHubClient } from './signalr/client'; diff --git a/frontend/src/shared/styles/common.css b/frontend/src/shared/styles/common.css deleted file mode 100644 index 48a209c7..00000000 --- a/frontend/src/shared/styles/common.css +++ /dev/null @@ -1,421 +0,0 @@ -/* Shared design tokens and base UI elements */ -:root { - --spacing-xs: 4px; - --spacing-sm: 8px; - --spacing-md: 12px; - --spacing-lg: 16px; - --spacing-xl: 20px; - --spacing-2xl: 24px; - --spacing-3xl: 32px; - --spacing-4xl: 40px; - - --radius-sm: 6px; - --radius-md: 10px; - --radius-lg: 14px; - --radius-xl: 18px; - --radius-full: 999px; - - --font-xs: 12px; - --font-sm: 13px; - --font-base: 14px; - --font-lg: 16px; - --font-xl: 20px; - --font-2xl: 24px; - --font-3xl: 32px; - - --font-normal: 400; - --font-medium: 500; - --font-semibold: 600; - --font-bold: 700; - - --transition-fast: 120ms ease; - --transition-base: 220ms ease; - --transition-slow: 360ms ease; - - --focus-ring: 0 0 0 3px var(--accent-soft); -} - -input[type='text'], -input[type='number'], -input[type='email'], -input[type='password'], -input[type='search'], -input[type='tel'], -input[type='url'], -textarea, -.input-field, -.setting-input, -.table-input { - width: 100%; - padding: 10px 14px; - background-color: var(--input-bg); - border: 1px solid var(--input-border); - border-radius: var(--radius-md); - color: var(--input-text); - font-size: var(--font-base); - font-family: inherit; - line-height: 1.5; - transition: - border-color var(--transition-fast), - box-shadow var(--transition-fast), - transform var(--transition-fast); -} - -textarea { - min-height: 90px; - resize: vertical; -} - -input:focus, -textarea:focus, -.input-field:focus, -.setting-input:focus, -.table-input:focus { - outline: none; - border-color: var(--accent-primary); - box-shadow: var(--focus-ring); - transform: translateY(-1px); -} - -input::placeholder, -textarea::placeholder, -.input-field::placeholder, -.setting-input::placeholder, -.table-input::placeholder { - color: var(--input-placeholder); -} - -select, -.setting-select { - width: 100%; - padding: 10px 40px 10px 14px; - background-color: var(--input-bg); - border: 1px solid var(--input-border); - border-radius: var(--radius-md); - color: var(--input-text); - font-size: var(--font-base); - font-family: inherit; - line-height: 1.5; - appearance: none; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%238a8178' d='M6 9L1 4h10z'/%3E%3C/svg%3E"); - background-repeat: no-repeat; - background-position: right 12px center; - transition: - border-color var(--transition-fast), - box-shadow var(--transition-fast); -} - -[data-theme='dark'] select option, -[data-theme='dark'] .setting-select option { - background-color: var(--bg-tertiary); - color: var(--text-primary); -} - -[data-theme='light'] select option, -[data-theme='light'] .setting-select option { - background-color: var(--bg-secondary); - color: var(--text-primary); -} - -select option:checked, -.setting-select option:checked { - background-color: var(--accent-soft); - color: var(--text-primary); -} - -[data-theme='dark'] select, -[data-theme='dark'] .setting-select { - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23cfc6bb' d='M6 9L1 4h10z'/%3E%3C/svg%3E"); -} - -select:focus, -.setting-select:focus { - outline: none; - border-color: var(--accent-primary); - box-shadow: var(--focus-ring); -} - -button { - font-family: inherit; -} - -.btn, -.browse-btn, -.start-btn { - display: inline-flex; - align-items: center; - justify-content: center; - gap: var(--spacing-sm); - padding: 10px 16px; - border: none; - border-radius: var(--radius-md); - font-size: var(--font-base); - font-weight: var(--font-semibold); - cursor: pointer; - transition: - transform var(--transition-fast), - box-shadow var(--transition-fast), - background-color var(--transition-fast); - white-space: nowrap; -} - -.btn:disabled, -.browse-btn:disabled, -.start-btn:disabled { - opacity: 0.6; - cursor: not-allowed; - transform: none; - box-shadow: none; -} - -.btn-primary, -.start-btn { - background: linear-gradient(135deg, var(--accent-primary), var(--accent-hover)); - color: #fff; - box-shadow: var(--shadow-sm); -} - -.btn-primary:hover:not(:disabled), -.start-btn:hover:not(:disabled) { - transform: translateY(-1px); - box-shadow: var(--shadow-md); -} - -.btn-secondary, -.browse-btn { - background-color: var(--bg-tertiary); - color: var(--text-primary); - border: 1px solid var(--border-primary); -} - -.btn-secondary:hover:not(:disabled), -.browse-btn:hover:not(:disabled) { - border-color: var(--accent-primary); - box-shadow: var(--shadow-sm); -} - -.btn-success { - background: linear-gradient(135deg, var(--success-primary), var(--success-hover)); - color: #fff; - box-shadow: var(--shadow-sm); -} - -.btn-danger { - background: linear-gradient(135deg, var(--danger-primary), var(--danger-hover)); - color: #fff; - box-shadow: var(--shadow-sm); -} - -.btn-danger:hover:not(:disabled), -.btn-success:hover:not(:disabled) { - transform: translateY(-1px); - box-shadow: var(--shadow-md); -} - -.btn-icon { - width: 18px; - height: 18px; - object-fit: contain; - filter: brightness(0) invert(1); -} - -[data-theme='light'] .btn-icon { - filter: none; -} - -.btn-icon-small { - width: 16px; - height: 16px; - object-fit: contain; - filter: brightness(0) invert(1); -} - -[data-theme='light'] .btn-icon-small { - filter: none; -} - -.input-group { - display: flex; - gap: var(--spacing-sm); - align-items: stretch; -} - -.input-group > *:first-child { - flex: 1; -} - -label, -.input-label, -.setting-label { - display: block; - margin-bottom: var(--spacing-sm); - color: var(--text-secondary); - font-size: var(--font-base); - font-weight: var(--font-medium); -} - -.message { - padding: 12px 16px; - border-radius: var(--radius-md); - border: 1px solid transparent; - font-size: var(--font-base); - display: flex; - align-items: center; - gap: var(--spacing-sm); -} - -.message-success { - background-color: rgba(16, 185, 129, 0.12); - border-color: rgba(16, 185, 129, 0.4); - color: var(--success-primary); -} - -.message-error { - background-color: rgba(239, 68, 68, 0.12); - border-color: rgba(239, 68, 68, 0.4); - color: var(--danger-primary); -} - -.message-warning { - background-color: rgba(245, 158, 11, 0.12); - border-color: rgba(245, 158, 11, 0.4); - color: #b45309; -} - -.app-notification { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--spacing-lg); - padding: 14px 18px; - border-radius: var(--radius-lg); - box-shadow: var(--shadow-sm); - font-weight: var(--font-semibold); - animation: notificationIn 220ms ease; - will-change: transform, opacity; -} - -.app-notification.message-success { - border-left: 4px solid var(--success-primary); -} - -.app-notification.message-warning { - border-left: 4px solid #b45309; -} - -.app-notification.message-error { - border-left: 4px solid var(--danger-primary); -} - -.app-notification--closing { - animation: notificationOut 180ms ease forwards; -} - -.notification-close { - border: none; - background: transparent; - color: var(--text-primary); - font-size: 16px; - padding: 0; - border-radius: var(--radius-full); - border: 1px solid transparent; - cursor: pointer; - transition: - background-color var(--transition-fast), - border-color var(--transition-fast), - color var(--transition-fast); -} - -.notification-close:hover { - background: transparent; - border-color: transparent; -} - -.notification-close__icon { - width: 12px; - height: 12px; - display: block; -} - -.notification-text { - display: flex; - flex-direction: column; - gap: 2px; -} - -.notification-title { - font-weight: var(--font-semibold); - color: inherit; -} - -.notification-detail { - font-weight: var(--font-medium); - color: var(--text-secondary); -} - -.loading { - padding: var(--spacing-3xl); - text-align: center; - color: var(--text-tertiary); - font-size: var(--font-base); -} - -.empty-state { - text-align: center; - padding: var(--spacing-4xl) var(--spacing-lg); - color: var(--text-tertiary); - font-size: var(--font-lg); -} - -.menu-title { - font-size: var(--font-3xl); - font-weight: var(--font-bold); - color: var(--text-primary); -} - -.menu-header { - display: flex; - justify-content: space-between; - align-items: center; - gap: var(--spacing-lg); - flex-wrap: wrap; -} - -@media (max-width: 900px) { - .menu-title { - font-size: var(--font-2xl); - } -} - -@keyframes fadeInUp { - from { - opacity: 0; - transform: translateY(8px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -@keyframes notificationIn { - from { - opacity: 0; - transform: translateY(-8px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -@keyframes notificationOut { - from { - opacity: 1; - transform: translateY(0); - } - to { - opacity: 0; - transform: translateY(-8px); - } -} diff --git a/frontend/src/shared/styles/index.css b/frontend/src/shared/styles/index.css deleted file mode 100644 index c8cc60ea..00000000 --- a/frontend/src/shared/styles/index.css +++ /dev/null @@ -1,143 +0,0 @@ -@import './common.css'; - -@font-face { - font-family: 'Space Grotesk'; - src: url('../../../assets/fonts/SpaceGrotesk-400.ttf') format('truetype'); - font-weight: 400; - font-style: normal; - font-display: swap; -} - -@font-face { - font-family: 'Space Grotesk'; - src: url('../../../assets/fonts/SpaceGrotesk-500.ttf') format('truetype'); - font-weight: 500; - font-style: normal; - font-display: swap; -} - -@font-face { - font-family: 'Space Grotesk'; - src: url('../../../assets/fonts/SpaceGrotesk-600.ttf') format('truetype'); - font-weight: 600; - font-style: normal; - font-display: swap; -} - -@font-face { - font-family: 'Space Grotesk'; - src: url('../../../assets/fonts/SpaceGrotesk-700.ttf') format('truetype'); - font-weight: 700; - font-style: normal; - font-display: swap; -} - -@font-face { - font-family: 'IBM Plex Mono'; - src: url('../../../assets/fonts/IBMPlexMono-400.ttf') format('truetype'); - font-weight: 400; - font-style: normal; - font-display: swap; -} - -@font-face { - font-family: 'IBM Plex Mono'; - src: url('../../../assets/fonts/IBMPlexMono-500.ttf') format('truetype'); - font-weight: 500; - font-style: normal; - font-display: swap; -} - -* { - margin: 0; - padding: 0; - box-sizing: border-box; - scrollbar-width: thin; - scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track); -} - -:root { - --font-sans: 'Space Grotesk', 'Sora', 'Trebuchet MS', sans-serif; - --font-mono: 'IBM Plex Mono', 'Cascadia Mono', 'Consolas', monospace; -} - -body { - font-family: var(--font-sans); - background-color: var(--bg-primary); - color: var(--text-primary); - min-height: 100vh; - overflow: hidden; - transition: - background-color var(--transition-base), - color var(--transition-base); - position: relative; -} - -body::before, -body::after { - content: ''; - position: fixed; - inset: 0; - pointer-events: none; - z-index: 0; -} - -body::before { - background-image: - radial-gradient(700px 500px at 10% 10%, rgba(15, 118, 110, 0.18), transparent 60%), - radial-gradient(500px 400px at 90% -10%, rgba(248, 160, 120, 0.16), transparent 60%), - radial-gradient(600px 500px at 80% 80%, rgba(45, 212, 191, 0.12), transparent 60%); - opacity: 0.9; -} - -body::after { - background-image: linear-gradient(120deg, rgba(255, 255, 255, 0.08), transparent 55%); - mix-blend-mode: soft-light; - opacity: 0.6; -} - -#root { - width: 100vw; - height: 100vh; - position: relative; - z-index: 1; -} - -::selection { - background: var(--accent-primary); - color: #fff; -} - -::-webkit-scrollbar { - width: 10px; - height: 10px; -} - -::-webkit-scrollbar-track { - background: var(--scrollbar-track); - border-radius: 8px; -} - -::-webkit-scrollbar-thumb { - background: var(--scrollbar-thumb); - border-radius: 8px; -} - -::-webkit-scrollbar-thumb:hover { - background: var(--scrollbar-thumb-hover); -} - -.no-animations *, -.no-animations *::before, -.no-animations *::after { - animation: none !important; - transition: none !important; -} - -@media (prefers-reduced-motion: reduce) { - * { - animation-duration: 0.01ms !important; - animation-iteration-count: 1 !important; - transition-duration: 0.01ms !important; - } -} diff --git a/frontend/src/shared/styles/theme.css b/frontend/src/shared/styles/theme.css deleted file mode 100644 index 452ada61..00000000 --- a/frontend/src/shared/styles/theme.css +++ /dev/null @@ -1,98 +0,0 @@ -/* Theme color tokens */ -[data-theme='light'] { - --bg-primary: #f8f4ee; - --bg-secondary: #f3eee7; - --bg-tertiary: #ffffff; - --bg-hover: #ece4d9; - --bg-active: #e2d7c8; - - --text-primary: #1d1a16; - --text-secondary: #3b342d; - --text-tertiary: #6b6259; - - --border-primary: #e4dbcf; - --border-secondary: #efe6dc; - - --accent-primary: #0f766e; - --accent-hover: #0c5f58; - --accent-active: #0a4b46; - --accent-soft: rgba(15, 118, 110, 0.18); - - --success-primary: #0f8a5f; - --success-hover: #0c6f4c; - --success-active: #0a5b3e; - - --danger-primary: #b42318; - --danger-hover: #8f1e14; - - --sidebar-bg: #f1ebe2; - --sidebar-border: #e1d6c8; - --sidebar-item-hover: #e7ded2; - --sidebar-item-active: #d7f1ee; - --sidebar-text: #3b342d; - --sidebar-text-active: #0f766e; - --sidebar-text-secondary: #6b6259; - - --input-bg: #ffffff; - --input-border: #d7ccbe; - --input-text: #1d1a16; - --input-placeholder: #7b7067; - - --scrollbar-track: #eee4d7; - --scrollbar-thumb: #cbbfb1; - --scrollbar-thumb-hover: #b7a99c; - - --shadow-xs: 0 1px 2px rgba(26, 20, 14, 0.08); - --shadow-sm: 0 6px 18px rgba(26, 20, 14, 0.08); - --shadow-md: 0 12px 28px rgba(26, 20, 14, 0.12); - --shadow-lg: 0 22px 50px rgba(26, 20, 14, 0.18); -} - -[data-theme='dark'] { - --bg-primary: #151311; - --bg-secondary: #1c1916; - --bg-tertiary: #231f1b; - --bg-hover: #2a2521; - --bg-active: #312b26; - - --text-primary: #f5efe6; - --text-secondary: #d7cfc4; - --text-tertiary: #a69d91; - - --border-primary: #302b25; - --border-secondary: #3a342e; - - --accent-primary: #2dd4bf; - --accent-hover: #18b2a1; - --accent-active: #11907f; - --accent-soft: rgba(45, 212, 191, 0.22); - - --success-primary: #2bd68f; - --success-hover: #1db577; - --success-active: #169560; - - --danger-primary: #f97066; - --danger-hover: #e44f43; - - --sidebar-bg: #171411; - --sidebar-border: #2b2620; - --sidebar-item-hover: #211c18; - --sidebar-item-active: #0f3e3a; - --sidebar-text: #e0d7cb; - --sidebar-text-active: #2dd4bf; - --sidebar-text-secondary: #a69d91; - - --input-bg: #2a241f; - --input-border: #3a332c; - --input-text: #f5efe6; - --input-placeholder: #a69d91; - - --scrollbar-track: #1b1713; - --scrollbar-thumb: #3a332c; - --scrollbar-thumb-hover: #4a4138; - - --shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.35); - --shadow-sm: 0 8px 18px rgba(0, 0, 0, 0.35); - --shadow-md: 0 16px 30px rgba(0, 0, 0, 0.4); - --shadow-lg: 0 28px 60px rgba(0, 0, 0, 0.45); -} diff --git a/frontend/src/shared/utils/job.ts b/frontend/src/shared/utils/job.ts deleted file mode 100644 index 09d9b72e..00000000 --- a/frontend/src/shared/utils/job.ts +++ /dev/null @@ -1,146 +0,0 @@ -import type { SheetJob } from '@/shared/contexts/JobContextType'; -import { formatUserDateTime, formatUserTime } from './time'; - -export interface LogEntry { - message: string; - level?: string; - timestamp?: string; - row?: number; - rowStatus?: string; -} - -export interface RowLogGroup { - key: string; - row?: number; - status?: string; - entries: LogEntry[]; -} - -export const statusKey = (status: string): string => { - const normalized = status.toLowerCase(); - if (normalized === 'running') return 'processing'; - if (normalized === 'failed') return 'error'; - return normalized; -}; - -export const progressColor = (status: string): string => { - switch (status.toLowerCase()) { - case 'pending': - return '#9ca3af'; - case 'running': - return '#3b82f6'; - case 'paused': - return '#f59e0b'; - case 'completed': - return '#10b981'; - case 'error': - case 'failed': - case 'cancelled': - return '#ef4444'; - default: - return 'var(--accent-primary)'; - } -}; - -export const deriveGroupName = (workbookPath: string, fallback: string): string => { - if (!workbookPath) return fallback; - const parts = workbookPath.split(/[/\\]/); - return parts[parts.length - 1] || fallback; -}; - -export const formatLogEntry = ( - entry: LogEntry, - jobLabel: string | undefined, - language: string, -): string => { - const timeValue = formatUserTime(entry.timestamp, language); - const time = timeValue ? `[${timeValue}] ` : ''; - const level = entry.level ? `${entry.level}: ` : ''; - const job = jobLabel ? `${jobLabel}: ` : ''; - return `${time}${level}${job}${entry.message}`; -}; - -export const groupLogsByRow = (logs: LogEntry[]): RowLogGroup[] => { - const groups: RowLogGroup[] = []; - const map = new Map(); - for (const entry of logs) { - const key = entry.row != null ? `row:${entry.row}` : 'general'; - let group = map.get(key); - if (!group) { - group = { key, row: entry.row, status: entry.rowStatus, entries: [] }; - map.set(key, group); - groups.push(group); - } - group.entries.push(entry); - if (entry.rowStatus) group.status = entry.rowStatus; - } - return groups; -}; - -export const getSheetStats = (sheet: SheetJob) => { - const completedSlides = Math.min(sheet.currentRow, sheet.totalRows); - const isFailed = sheet.status === 'Failed' || sheet.status === 'Cancelled'; - const failedSlides = isFailed ? Math.max(sheet.totalRows - completedSlides, 0) : 0; - const processingSlides = - sheet.status === 'Running' - ? Math.max(sheet.totalRows - completedSlides, 0) - : sheet.status === 'Pending' - ? sheet.totalRows - : 0; - return { completedSlides, failedSlides, processingSlides }; -}; - -export const summarizeSheets = (sheets: SheetJob[]) => { - let completedJobs = 0, - processingJobs = 0, - failedJobs = 0; - let totalSlides = 0, - completedSlides = 0, - processingSlides = 0, - failedSlides = 0; - - for (const sheet of sheets) { - if (sheet.status === 'Completed') completedJobs++; - else if (sheet.status === 'Running' || sheet.status === 'Pending') processingJobs++; - else if (sheet.status === 'Failed' || sheet.status === 'Cancelled') failedJobs++; - - const stats = getSheetStats(sheet); - completedSlides += stats.completedSlides; - processingSlides += stats.processingSlides; - failedSlides += stats.failedSlides; - totalSlides += sheet.totalRows ?? 0; - } - - return { - completedJobs, - processingJobs, - failedJobs, - totalSlides, - completedSlides, - processingSlides, - failedSlides, - }; -}; - -export const summarizeSheetsSimple = ( - sheets: Array<{ status: string; currentRow: number; totalRows: number }>, -) => { - let completedSlides = 0, - failedSlides = 0, - totalSlides = 0; - - for (const sheet of sheets) { - const total = sheet.totalRows ?? 0; - const done = Math.min(sheet.currentRow ?? 0, total); - totalSlides += total; - completedSlides += done; - if (sheet.status === 'Failed' || sheet.status === 'Cancelled') { - failedSlides += Math.max(total - done, 0); - } - } - - return { completedSlides, failedSlides, totalSlides }; -}; - -export const formatTime = (value: string | undefined, language: string): string => - value ? formatUserDateTime(value, language) : ''; diff --git a/frontend/src/shared/utils/paths.test.ts b/frontend/src/shared/utils/paths.test.ts deleted file mode 100644 index ced62f2f..00000000 --- a/frontend/src/shared/utils/paths.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { getAssetPath } from './paths'; - -describe('getAssetPath', () => { - const original = window.getAssetPath; - - afterEach(() => { - if (original) { - window.getAssetPath = original; - } else { - delete (window as { getAssetPath?: (...parts: string[]) => string }).getAssetPath; - } - }); - - it('uses window.getAssetPath when available', () => { - const spy = vi.fn((...parts: string[]) => `custom/${parts.join('/')}`); - window.getAssetPath = spy; - const result = getAssetPath('images', 'app.png'); - expect(result).toBe('custom/images/app.png'); - expect(spy).toHaveBeenCalledWith('images', 'app.png'); - }); - - it('falls back to assets path when window helper missing', () => { - delete (window as { getAssetPath?: (...parts: string[]) => string }).getAssetPath; - const result = getAssetPath('images', 'app.png'); - expect(result).toBe('assets/images/app.png'); - }); -}); diff --git a/frontend/src/shared/utils/paths.ts b/frontend/src/shared/utils/paths.ts deleted file mode 100644 index 6780fb01..00000000 --- a/frontend/src/shared/utils/paths.ts +++ /dev/null @@ -1,7 +0,0 @@ -export const getAssetPath = (...parts: string[]): string => { - if (typeof window !== 'undefined' && typeof window.getAssetPath === 'function') { - return window.getAssetPath(...parts); - } - - return `assets/${parts.join('/')}`; -}; diff --git a/frontend/src/shared/utils/time.test.ts b/frontend/src/shared/utils/time.test.ts deleted file mode 100644 index 3945994d..00000000 --- a/frontend/src/shared/utils/time.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { formatUserDateTime, formatUserTime } from './time'; - -describe('time formatters', () => { - it('returns empty string for invalid values', () => { - expect(formatUserDateTime()).toBe(''); - expect(formatUserTime('')).toBe(''); - }); - - it('formats valid dates', () => { - const date = new Date(2025, 0, 15, 13, 45, 30); - const dateTime = formatUserDateTime(date, 'en-US'); - const time = formatUserTime(date, 'en-US'); - - expect(dateTime).toContain('2025'); - expect(time.length).toBeGreaterThan(0); - }); -}); diff --git a/frontend/src/shared/utils/time.ts b/frontend/src/shared/utils/time.ts deleted file mode 100644 index 6b2be0e4..00000000 --- a/frontend/src/shared/utils/time.ts +++ /dev/null @@ -1,48 +0,0 @@ -type FormatterBundle = { - dateTime: Intl.DateTimeFormat; - time: Intl.DateTimeFormat; -}; - -const formatters = new Map(); - -const getFormatters = (locale?: string | null): FormatterBundle => { - const key = locale && locale.trim().length > 0 ? locale : 'default'; - const cached = formatters.get(key); - if (cached) return cached; - - const localeArg = key === 'default' ? undefined : (locale ?? undefined); - const created = { - dateTime: new Intl.DateTimeFormat(localeArg, { - day: '2-digit', - month: '2-digit', - year: 'numeric', - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - }), - time: new Intl.DateTimeFormat(localeArg, { - timeStyle: 'medium', - }), - }; - formatters.set(key, created); - return created; -}; - -const toDate = (value?: string | number | Date) => { - if (!value) return null; - const date = value instanceof Date ? value : new Date(value); - if (Number.isNaN(date.getTime())) return null; - return date; -}; - -export const formatUserDateTime = (value?: string | number | Date, locale?: string | null) => { - const date = toDate(value); - if (!date) return ''; - return getFormatters(locale).dateTime.format(date); -}; - -export const formatUserTime = (value?: string | number | Date, locale?: string | null) => { - const date = toDate(value); - if (!date) return ''; - return getFormatters(locale).time.format(date); -}; diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts deleted file mode 100644 index 11f02fe2..00000000 --- a/frontend/src/vite-env.d.ts +++ /dev/null @@ -1 +0,0 @@ -/// diff --git a/frontend/src/vitest-env.d.ts b/frontend/src/vitest-env.d.ts deleted file mode 100644 index edfa8b53..00000000 --- a/frontend/src/vitest-env.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -/// -/// diff --git a/frontend/test/mocks/handlers.ts b/frontend/test/mocks/handlers.ts deleted file mode 100644 index c563b12e..00000000 --- a/frontend/test/mocks/handlers.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { http, HttpResponse } from 'msw'; -import { DEFAULT_BACKEND_URL } from '@/shared/services/signalr/constants'; - -export const handlers = [ - http.get(`${DEFAULT_BACKEND_URL}/health`, () => { - return HttpResponse.json({ IsRunning: true }); - }), -]; diff --git a/frontend/test/mocks/server.ts b/frontend/test/mocks/server.ts deleted file mode 100644 index e52fee0a..00000000 --- a/frontend/test/mocks/server.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { setupServer } from 'msw/node'; -import { handlers } from './handlers'; - -export const server = setupServer(...handlers); diff --git a/frontend/test/setup.ts b/frontend/test/setup.ts deleted file mode 100644 index 66e672ba..00000000 --- a/frontend/test/setup.ts +++ /dev/null @@ -1,27 +0,0 @@ -import '@testing-library/jest-dom'; -import { server } from './mocks/server'; - -// Mock localStorage -const localStorageMock = (function () { - let store: Record = {}; - return { - getItem: (key: string) => store[key] || null, - setItem: (key: string, value: string) => { - store[key] = value.toString(); - }, - removeItem: (key: string) => { - delete store[key]; - }, - clear: () => { - store = {}; - }, - }; -})(); - -Object.defineProperty(window, 'localStorage', { - value: localStorageMock, -}); - -beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); -afterEach(() => server.resetHandlers()); -afterAll(() => server.close()); diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json deleted file mode 100644 index 0c0c5cfa..00000000 --- a/frontend/tsconfig.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2020", - "useDefineForClassFields": true, - "lib": ["ES2020", "DOM", "DOM.Iterable"], - "module": "ESNext", - "skipLibCheck": true, - - /* Bundler mode */ - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "resolveJsonModule": true, - "isolatedModules": true, - "noEmit": true, - "jsx": "react-jsx", - "baseUrl": ".", - "paths": { - "@/*": ["src/*"] - }, - - /* Linting */ - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true - }, - "include": ["src", "test"], - "references": [{ "path": "./tsconfig.node.json" }] -} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json deleted file mode 100644 index aa93fa9e..00000000 --- a/frontend/tsconfig.node.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "compilerOptions": { - "composite": true, - "emitDeclarationOnly": true, - "declarationDir": "dist-types", - "outDir": "dist-types", - "skipLibCheck": true, - "module": "ESNext", - "moduleResolution": "bundler", - "allowSyntheticDefaultImports": true, - "strict": true, - "baseUrl": ".", - "paths": { - "@/*": ["src/*"] - } - }, - "include": ["vite.config.ts", "electron", "src/shared/locales/**/*.ts"] -} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts deleted file mode 100644 index 7aa6456d..00000000 --- a/frontend/vite.config.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { defineConfig } from 'vitest/config'; -import react from '@vitejs/plugin-react'; -import electron from 'vite-plugin-electron'; -import renderer from 'vite-plugin-electron-renderer'; -import { readFileSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; - -const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf-8')) as { - version?: string; -}; - -export default defineConfig({ - plugins: [ - react({ - // Enable React Compiler optimizations in development - babel: { - plugins: [ - // Babel plugin for automatic React runtime - ], - }, - }), - electron([ - { - entry: 'electron/main.ts', - onstart(options) { - options.startup(); - }, - vite: { - build: { - outDir: 'dist-electron', - }, - }, - }, - { - entry: 'electron/preload.ts', - onstart(options) { - options.reload(); - }, - vite: { - build: { - outDir: 'dist-electron', - }, - }, - }, - ]), - renderer(), - ], - server: { - port: 65000, - strictPort: true, - }, - resolve: { - alias: { - '@': fileURLToPath(new URL('./src', import.meta.url)), - }, - }, - define: { - __APP_VERSION__: JSON.stringify(pkg.version ?? '0.0.0'), - }, - build: { - // Optimize chunk splitting for better caching - rollupOptions: { - output: { - manualChunks: (id) => { - if (id.includes('node_modules')) { - if (id.includes('react') || id.includes('react-dom')) { - return 'vendor-react'; - } - if (id.includes('@microsoft/signalr')) { - return 'vendor-signalr'; - } - // Group other small dependencies into a single vendor chunk to avoid too many requests - return 'vendor'; - } - }, - }, - }, - // Enable minification with terser for smaller bundles - minify: 'esbuild', - // Target modern browsers for smaller output - target: 'esnext', - // Enable source maps for debugging (disable in production if not needed) - sourcemap: false, - // Reduce chunk size warnings threshold - chunkSizeWarningLimit: 500, - }, - // Optimize dependencies - optimizeDeps: { - include: ['react', 'react-dom', '@microsoft/signalr'], - }, - test: { - environment: 'jsdom', - setupFiles: 'test/setup.ts', - globals: true, - }, -}); diff --git a/global.json b/global.json new file mode 100644 index 00000000..20d90b54 --- /dev/null +++ b/global.json @@ -0,0 +1,10 @@ +{ + "sdk": { + "version": "10.0.100", + "rollForward": "latestMajor", + "allowPrerelease": true + }, + "test": { + "runner": "Microsoft.Testing.Platform" + } +} \ No newline at end of file diff --git a/nuget.config b/nuget.config new file mode 100644 index 00000000..2027d839 --- /dev/null +++ b/nuget.config @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/plans/domain-reference.md b/plans/domain-reference.md new file mode 100644 index 00000000..7d835066 --- /dev/null +++ b/plans/domain-reference.md @@ -0,0 +1,963 @@ +# SlideGenerator V2 — Domain Reference + +**Source of truth:** the V2 codebase at `V:\Code\cs\SlideGenerator` on branch `develop`, as of 2026-09-02. +**Audience:** an external UI/UX design agent. This document describes what the application *is and does* according to the code. It does not propose UI. + +**Read this first — the repo's own docs are stale.** `CLAUDE.md` still describes a "SlideGenerator.Stdio" JSON-RPC/IPC sidecar and a Tauri frontend. **Neither exists in V2.** V2 is a single Avalonia desktop application (`SlideGenerator.Desktop`) that runs the entire backend in-process. Where `CLAUDE.md` and the code disagree, the code wins. `plans/idea.md`, `plans/ui.md`, `plans/status.md` (all outside git) describe the *intended* frontend and are current as design intent, not as a spec of shipped behaviour. + +--- + +## 1. Executive Summary + +SlideGenerator automates the production of PowerPoint presentations from tabular data and a template slide. + +The user has: (a) an Excel/CSV workbook whose rows are records (people, products, awards, …), and (b) a PowerPoint file containing one slide designed as a template, with text placeholders and picture shapes. The user wants: one output presentation where the template slide is repeated once per data row, with each copy's placeholders filled from that row's cells and its picture shapes filled with images named/linked in that row (downloaded, cropped to fit, face-aware where asked). + +In V2 the user builds a reusable **Recipe** that binds template placeholders/shapes to worksheet columns, then starts a **Run** of that recipe against a chosen output folder. Each Run fans out into one **Job** per (template-slide × worksheet) pair; each Job executes a fixed 4-phase pipeline in the background, reporting live progress. Completed and running Runs are browsable on a **Runs** page. Everything domain-related (recipes, run history, settings) is stored locally in SQLite/JSON; the network is used only for downloading row images, checking for app updates, and fetching the About page's contributor/sponsor lists. + +Technically: a .NET 10 / Avalonia desktop app, Windows-first, MVVM (CommunityToolkit.Mvvm), 12 modules layered Foundation → Domain → Application → Host. Document I/O is Syncfusion (Excel + PowerPoint); templating is Mustache (Stubble); image work is NetVips + OpenCV YuNet face detection; updates are Velopack against GitHub Releases. + +--- + +## 2. Core Domain + +### Plain language + +- A **Recipe** is a saved binding configuration. It says: "for template slide *X* in presentation *P*, feed it from worksheet(s) *W*; put column *Name* into placeholder `{{Name}}`; put the image at column *Photo* into the shape called `Picture 1`, cropping around the face." +- A Recipe holds one or more **Mappings**. Each Mapping is: *one template slide* + *its text rules* + *its image rules* + *one or more worksheet data sources*. A Recipe with three Mappings produces three kinds of slide. +- A **Run** (called a *Request* in code) is one execution of a Recipe: "generate now, save the `.pptx` files under this folder." A Run immediately expands into **Jobs** — one Job per (Mapping × worksheet source). Each Job writes exactly one output presentation file. +- A **Job** walks four phases in order: create the output file → add one slide per row → fill text → fill images. It can be paused/resumed/stopped **at the Run level only** (never per-Job), and it resumes automatically after an app crash from wherever it stopped. +- **Settings** are global: appearance (theme/language/motion — UI only), performance (`MaxConcurrentJobs` — affects generation), network (proxy, retry, download size cap — affects generation). +- The **About** page shows version/update status and live GitHub contributor/sponsor lists. + +### Technical representation + +``` +Recipe (SQLite row: id, name, JSON, timestamps) + └── Mappings : List + ├── Template : PresentationSource(PresentationIdentifier file, SlideIdentifier 1-based index) + ├── Sources : List(WorkbookIdentifier, WorksheetIdentifier, UsedColumns?, RowFilter?) + ├── TextInstructions : List(Set, List) + └── ImageInstructions : List(Set, List, + ImageEditInstruction(List), FallbackImagePath?) + +Run: Request(RecipeId, Name, OutputType, SaveFolder, AllowLocalPaths) + │ Service.CreateAsync → requestId (GUID string) + └── Jobs : one JobSpecification per (Mapping × WorksheetSource), fully resolved, id = 0-based ordinal int + JobSnapshot(RequestId, JobId, JobStatus, JobPhase, CurrentIndex, JobSpecification, Timestamp, TotalRows?) + +Persistence (single Data.db): Recipes | Requests (write-once) | Jobs (current-state, ~1s buffered) +``` + +--- + +## 3. Domain Concepts + +### 3.1 Recipe + +- **Type:** `SlideGenerator.Recipe.Models.Recipe(IReadOnlyList Mappings)`. Storage wrapper: `RecipeEntry(int Id, string Name, Recipe Recipe, DateTimeOffset Created, DateTimeOffset Updated)`. +- **Purpose:** a reusable, named binding of template artefacts to data columns. Independent of any Run. +- **Represents:** the user's answer to "how does my spreadsheet fill my template." +- **Properties:** integer `Id` (SQLite autoincrement), `Name` (free text, the only user-editable metadata), `Mappings` (flat list — see 3.2), `CreatedTimestamp`/`UpdatedTimestamp` (UTC). +- **Lifecycle:** created empty (`Recipe([])`) or by import; edited in the Recipe Editor; saved (insert on first save, update thereafter); duplicated; exported to a `.recipe` file; deleted permanently. No soft-delete, no versioning. +- **Can do:** be run any number of times, concurrently; be edited while Runs derived from it are in flight (a Job carries a fully-resolved `JobSpecification`, so editing/deleting the Recipe does not disturb running Jobs); expose `GetReferencedFiles()` (all distinct workbook + presentation paths). +- **Cannot do:** carry run history (that lives on Runs), carry per-recipe UI preferences (e.g. "always open me in Advanced mode" — **not stored**), reference data by anything other than an absolute/relative file path + name/index. +- **Depends on:** file paths to workbooks and presentations that exist on disk at run time (not enforced at save time). +- **Depended on by:** Runs (via `Request.RecipeId`), the Recipe Editor, `.recipe` export/import. +- **Invariants:** `Mappings` is never `null` after load — `SqliteRecipeRepository.DbReadEntry` and the import path both normalise a missing/`null` `mappings` to `[]`; a corrupt JSON blob deserialises to `Recipe([])` rather than throwing. +- **Validation:** essentially none at the repository layer. The Editor requires a non-blank `Name` to enable Save (see 3.13); it does **not** require any Mappings, resolved bindings, or existing files to save. Stronger checks (`HasTemplate`, no unresolved bindings) gate only "Save and run." + +### 3.2 Mapping + +- **Type:** `Mapping(IReadOnlyList Sources, PresentationSource Template, IReadOnlyList TextInstructions, IReadOnlyList ImageInstructions)`. +- **Represents:** one template slide plus the rules and data feeding it. Every `WorksheetSource` in a Mapping is rendered through the *same* template slide and the *same* text/image instructions — the one thing the removed graph model expressed that the flat list still needs (a nested list, no ids). +- **Has no name or id of its own.** The Editor labels a Mapping `"Slide {index}"` (`MappingEditSession.Label`). +- **Lifecycle:** exists only inside a `Recipe`. Added via the template picker, removed, reordered in the Editor's mapping navigator (shown only when ≥ 2 Mappings exist). +- **Fan-out rule:** at Run time each Mapping expands to `Sources.Count` Jobs (`Service.BuildJobs` = `Mappings.SelectMany(m => m.Sources.Select(...))`). + +### 3.3 WorksheetSource + +- **Type:** `WorksheetSource(WorkbookIdentifier Workbook, WorksheetIdentifier Worksheet, IReadOnlySet? UsedColumns = null, RowFilter? RowFilter = null)`. +- **Represents:** one worksheet as a data feed, optionally column- and row-filtered. +- `WorkbookIdentifier(string BookPath, string? BookPassword, string? Separator)` — file path (rooted paths normalised via `Path.GetFullPath`), optional password, optional CSV/TSV separator. `GetBookType()` derives `WorkbookType` (`Xls`, `Xlsx`, `Xltx`, `Ods`, `Csv`, `Tsv`) from the extension. +- `WorksheetIdentifier(string SheetName)` — by name. +- `ColumnIdentifier(string ColumnName)` — by header name (row 1 is the header). +- `UsedColumns == null` means "all columns visible to instructions" and is *forward-looking* — the Editor collapses "every box checked" back to `null` on save so an untouched source keeps tracking future header changes rather than freezing today's list. +- `RowFilter == null` means "all rows." + +### 3.4 RowFilter + +- **Type:** polymorphic record `RowFilter` (STJ discriminator `"mode"`), enum `RowFilterMode : byte { All, IndexRange, PartitionBlock }`. + - `AllRowFilter` — every data row. + - `IndexRangeFilter(int Start, int End)` — 1-based, inclusive. Index 1 = first data row (= worksheet row 2). + - `PartitionBlockFilter(int PartitionIndex, int PartitionCount)` — divides the data rows into `PartitionCount` equal blocks and takes block `PartitionIndex` (0-based). Used to split one worksheet across several Runs/machines. +- `GetIndices(int dataCount)` returns the selected 1-based data-row indices. +- **Persistence note:** `null` and `AllRowFilter` are treated as identical everywhere; `JobsRepository` never materialises `AllRowFilter` (stores `RowFilterType = null`). + +### 3.5 PresentationSource ("the template") + +- **Type:** `PresentationSource(PresentationIdentifier Presentation, SlideIdentifier Slide)`. +- `PresentationIdentifier(string PresentationPath, string? PresentationPassword)` — `PresentationType` (`Potx`, `Pptx`, `Ppsx`) from extension. +- `SlideIdentifier(int SlideIndex)` — 1-based, clamped to ≥ 1. +- **There is no "Template" entity.** A template is just this pair: a presentation file on disk + a slide index within it. See §9. + +### 3.6 TextInstruction + +- **Type:** `TextInstruction(IReadOnlySet Placeholders, IReadOnlyList Columns)`. +- **Semantics (`SlideGenerationWorkload.BuildRowTextValues`):** for each instruction, the **first non-empty cell** across `Columns` (in order) is the value; **every** tag in `Placeholders` is set to that value. So `Columns` is a fallback chain, `Placeholders` is a fan-out. +- Placeholder tags are Mustache keys — the bare `Name` from `{{Name}}` (see §10, TextComposer/TemplateEngine). + +### 3.7 ImageInstruction + +- **Type:** `ImageInstruction(IReadOnlySet Shapes, IReadOnlyList Columns, ImageEditInstruction ImageEditInstruction, string? FallbackImagePath = null)`. +- `ShapeIdentifier(string ShapeName)` — the PowerPoint shape's name (e.g. `"Picture 1"`). +- `Columns` — fallback chain of cells holding an image URL or local path; first non-empty wins (`Utilities.GetSource`). +- `FallbackImagePath` — normalised absolute path; used when the row's own source is missing/invalid. +- `ImageEditInstruction(IReadOnlyList RoiOptions)` — an ordered chain of crop strategies tried in turn; first that succeeds is used; if all fail the cropper falls back to a centre crop. + +### 3.8 RoiOption (crop strategy) + +- **Type:** abstract `RoiOption` (`SlideGenerator.Image.Cropping`), enum `RoiMode : byte { Anchor, Interest }`. + - `AnchorOption { AnchorType Type; Vector2 Ratio; Vector2 Pivot }` — geometry-based, optionally face-aware. + `AnchorType : byte { Image, Face, Eyes, Nose, Mouth }`. `Face`/`Eyes`/`Nose`/`Mouth` require a detected face (OpenCV YuNet); return no result if none found, falling through to the next option. + - `InterestOption { InterestType Type }` — content-aware via libvips. `InterestType : byte { Entropy, Attention, Low, High, All }`. +- Cropping is in-memory end to end; the result is written straight into `IShape.ImageData` as PNG bytes. + +### 3.9 Request (the Run) + +- **Type:** `Request(int RecipeId, string Name, PresentationType OutputType, string SaveFolder, bool AllowLocalPaths = false)`. +- `SaveFolder` is validated non-blank and normalised at construction (throws otherwise). +- `OutputType` sets the output file extension (`.pptx` / `.potx` / `.ppsx`). +- **`AllowLocalPaths` is inert.** It is a `Request` field, a `Requests`-table column, and a Run-dialog checkbox — but `JobSpecification` has no such field, `Service.BuildJobs` never propagates it, and `SlideGenerationWorkload` therefore cannot read it. Local file paths in image cells are handled **unconditionally** (`if (File.Exists(source))` in `ResolveShapeImageAsync`): loaded and cropped in memory, no hard-link, no copy. The XML doc on `Request.AllowLocalPaths` ("hard-linked or copied") describes behaviour that does not exist in V2. **Flag for design: treat this checkbox as a no-op until the backend wires it.** +- **Persisted as** `RequestRecord(string RequestId, Request Request, string LogPath, DateTimeOffset CreatedAt)` — write-once, never updated. + +### 3.10 JobSpecification + +- **Type:** `JobSpecification(string WorkbookPath, string WorksheetName, IReadOnlySet? UsedColumns, RowFilter? RowFilter, string TemplatePresentationPath, int TemplateSlideIndex, IReadOnlyList TextInstructions, IReadOnlyList ImageInstructions, string OutputPath)`. +- **Every value resolved from the Recipe at spawn time.** A Job never re-reads the Recipe to run or resume. This is why a Recipe can be edited/deleted while its Runs execute. +- `OutputPath` = `{Request.SaveFolder}/{workbookFileStem}/{sanitizedWorksheetName}{outputExtension}` (`Service.BuildOutputPath`). **It encodes neither the Mapping nor the slide index** — so two Mappings that share one worksheet in the same Request produce the *same* output path, which is exactly the collision `FindDuplicateOutputPath` rejects at submit. **Design consequence: within one Run, a given worksheet can feed at most one Mapping.** + +### 3.11 JobSnapshot + +- **Type:** `JobSnapshot(string RequestId, int JobId, JobStatus JobStatus, JobPhase Phase, int CurrentIndex, JobSpecification Specification, DateTimeOffset Timestamp, int? TotalRows = null)`. +- `JobId` is a **plain 0-based ordinal** (position in the Request's Job list), not a GUID. +- `CurrentIndex` = rows completed within the current `Phase`; reset to 0 on every phase transition. Together `(Phase, CurrentIndex)` is the entire resume position. +- `TotalRows` = the worksheet's row count after `RowFilter`, known once the workload starts; `null` for jobs from older builds → Runs' progress bar goes indeterminate. +- Doubles as the **job-scoped progress payload** — there is no separate `JobProgress` DTO. + +### 3.12 JobStatus / JobPhase (state enums) + +- `JobStatus : byte { Pending, Running, Complete, Paused, Cancelled, Error }`. **All six values are used.** `Pending` = spawned but not yet picked up. +- `JobPhase : byte { Queued, CreatingOutput, CreatingSlides, FillingText, FillingImages, Done }`. + - **`Queued` is declared but never emitted.** `JobRunner.StartJobAsync` mints the first snapshot at `CreatingOutput`. Live phases are `CreatingOutput → CreatingSlides → FillingText → FillingImages → Done`. Forward-only, never regresses. + +### 3.13 Recipe Editor session state (Desktop-only) + +- `MappingEditSession(Mapping mapping)` — a mutable wrapper around one Mapping plus two `HashSet` "touched" sets (placeholder names / shape names the user has explicitly confirmed). Provides a stable object identity for the mapping navigator (a `Mapping` record compares by value, so its identity changes on every edit). +- `RecipeEditorViewModel` — the coordinator. Holds `Sessions` (one per Mapping), a `SelectedSession`, `IsDirty`, `IsGuided`, `GuidedStep`, and three child panels (`Canvas`, `TextBindings`, `Sources`). New Recipe → `IsGuided = true`; existing Recipe opened from the list → `IsGuided = false` (Advanced). +- `GuidedStep : { Template = 1, Data, Binding, Review }` — the 4-step wizard. Same panels as Advanced; the enum only chooses which panel(s) show. +- **Dirty tracking** is explicit (child panels raise `Changed`), not record-equality. Merely switching between Mappings to look at them does not mark dirty. + +### 3.14 Binding suggestion model (Editor) + +- `BindingMatcher.Match(placeholders, columns)` — pure, no I/O. Per name it produces a `BindingCandidate(name, BindingConfidence, column?, candidates)`. +- `BindingConfidence : { Exact, Normalized, Ambiguous, None }` — Exact = literal name match; Normalized = match after loose normalisation; Ambiguous = >1 exact-normalised OR any partial/substring match; None = nothing. +- `BindingDisplayResolver.Resolve` folds a *saved* binding (always wins → `Assigned`) or, failing that, the matcher's suggestion into `BindingDisplayState : { Assigned, Suggested, NeedsSelection, Unassigned }`: + - saved column, or `Exact` → **Assigned** (auto, silent) + - `Normalized`, not yet confirmed → **Suggested**; once "touched" → Assigned + - `Ambiguous` → **NeedsSelection** (user must pick from candidates) + - `None` → **Unassigned** +- `Summarize` gives the `(Assigned, Suggested, NeedsSelection, Unassigned)` tuple shown as "N ghép · N đề xuất · N cần chọn · N chưa gán." + +### 3.15 Settings (`Setting` record tree) + +See §12. + +### 3.16 Summaries (Editor support data, `SlideGenerator.Summarizer`) + +- `WorkbookSummary(FilePath, Name, IReadOnlyList)`; `WorksheetSummary(WorkbookIdentifier, WorksheetIdentifier, int Count, WorksheetPreview?)`; `WorksheetPreview(IReadOnlyList Headers, IReadOnlyList> Rows)` — up to 20 preview rows (`ISummarizationService.MaxPreviewRows`). +- `PresentationSummary(PresentationPath, IReadOnlyList)`; `SlideSummary(PresentationIdentifier, SlideIdentifier, IReadOnlyList Placeholders, IReadOnlyList ImageShapes, byte[]? Preview, SizeF SlideSize)`; `ShapeSummary(SlideIdentifier, ShapeIdentifier, RectangleF Bounds)`. +- **`Placeholders`** = distinct Mustache tags scanned from every shape's display text. +- **`ImageShapes`** = only shapes whose `ImageData != null` — i.e. shapes that already hold a picture in the template. An empty rectangle is invisible to the Editor (see §9). +- `Preview` = rendered PNG of the slide/thumbnail. + +### 3.17 ContentInfo (Cloud) + +`ContentInfo(Uri Uri, string? MimeType, uint? Length, string? Extension)` — result of inspecting a remote image URL (final URI after redirects + cloud resolution, MIME, byte length, file extension). `IsImage()` = MIME starts `image/`. + +### 3.18 About-page data + +- `Contributor(string Login, string AvatarUrl, string ProfileUrl, int Contributions)` — live from `api.github.com/repos/thnhmai06/SlideGenerator/contributors`, most contributions first. **No role/badge field** — the login→role map (crown/computer/paint icons in `plans/idea.md`) was never supplied. **Unknown / Not determined from the code.** +- `Supporter(string Login, string AvatarUrl, string ProfileUrl)` — from `raw.githubusercontent.com/.../data/sponsors.json` (published by a scheduled GitHub Action). Empty list = no sponsors yet, not an error. +- `UpdateCheckResult : { NotInstalled, UpToDate, UpdateDownloaded, Failed }`. + +--- + +## 4. Domain Relationships + +| From | To | Cardinality | Ownership / lifecycle | Independent existence | +|---|---|---|---|---| +| Recipe | Mapping | 1 → 0..N | Recipe **owns** Mappings (value objects in its JSON) | Mapping cannot exist outside a Recipe | +| Mapping | WorksheetSource | 1 → 1..N (Editor requires ≥1 to advance Guided; a raw Recipe may hold 0) | owned | no | +| Mapping | PresentationSource (template) | 1 → 1 (required) | Mapping **references** a file path; does not own the file | the `.pptx` is an external file, fully independent, reusable | +| Mapping | TextInstruction | 1 → 0..N | owned | no | +| Mapping | ImageInstruction | 1 → 0..N | owned | no | +| ImageInstruction | RoiOption | 1 → 0..N (ordered) | owned | no | +| Recipe | workbook file | N Mappings → M files (a Mapping's Sources may span several workbooks) | reference only | file independent, reusable across Recipes | +| Recipe | presentation file | N Mappings → M files | reference only | file independent, reusable across Recipes and Mappings | +| Run (Request) | Recipe | N → 1 (`RecipeId`) | Run captures a **fully-resolved snapshot** at spawn; no live link afterwards | Recipe may be edited/deleted while the Run executes; `RecipeId` on the record is informational only | +| Run (Request) | Job | 1 → 1..N (one per Mapping × WorksheetSource) | Run owns its Jobs; deleting the Run deletes its Job rows and `RequestRecord` | a Job has no meaning without its Request | +| Job | output `.pptx` file | 1 → 1 | Job writes it; nothing deletes it automatically (`PreflightCleanup` only overwrites the Job's *own* prior output on a fresh, non-resumed start) | file persists after the Run; user opens it via "Open folder" | +| Job | per-job download cache | 1 → 1 folder (`%TEMP%\SlideGenerator\{requestId}\{jobId}\`) | created in phase D, **deleted by `GeneratorJobObserver.OnTerminalAsync`** once the Job reaches a terminal (non-Paused) state | transient | +| Run | `.log` file | 1 → 1 (`RequestRecord.LogPath`, shared by every Job of the Run) | written during execution; **not deleted** on Run delete | persists on disk | +| Recipe | `.recipe` package | export/import only | a `.recipe` is a portable zip snapshot; importing creates a **new** Recipe row + copies bundled files into `Imported/` | fully independent artefact | +| Setting | everything | 1 global instance | `ISettingManager.Current`; persisted to `UserSettings.json` | — | + +**Not a relationship:** there is no Recipe↔Recipe link, no Mapping id/reference graph (the old `Node`/`Edge` model is gone), no Template registry, no "project" grouping Recipes. + +--- + +## 5. User Workflows + +Only workflows the code supports are listed. + +### 5.1 Create / edit a Recipe + +1. **Start:** Recipes page → "New" (blank Recipe, opens Guided) or a row's "Edit" (opens Advanced). Editor opens **inline inside the Recipes page** (`IsEditorOpen`/`Editor`), not as a separate destination. +2. **Intent:** define how a spreadsheet fills a template. +3. **Objects:** `RecipeEditorViewModel`, `MappingEditSession` per Mapping, child panels `SlideCanvasViewModel` / `TextBindingsViewModel` / `WorksheetSourcesViewModel`, `ISummaryCache` for previews. +4. **Actions (Guided order):** + - ① **Template** — pick a `.pptx/.potx/.ppsx` file, pick a slide → appends a Mapping. + - ② **Data** — add one or more workbooks; each contributes its first worksheet by default; per-source column checkboxes and a `RowFilterEditorViewModel` (All / IndexRange / PartitionBlock). + - ③ **Binding** — confirm text-placeholder → column and image-shape → column bindings (auto-suggested per §3.14). Guided splits this into a Text sub-group and an Image sub-group. + - ④ **Review** — shows the file count ("sẽ tạo N file" = one per worksheet source in the single Mapping) → Save, or "Save and run," or "Open Advanced mode." + - **Advanced** exposes all panels at once plus a mapping navigator (add / remove / move up / move down) and a per-shape inspector (reorder / remove `RoiOption`s, pick a fallback image). +5. **State changes:** editor `IsDirty` flips on any child `Changed`; `Id` goes from `null` to a real id on first Save. +6. **Validation:** Save enabled when `IsDirty && Name` non-blank (nothing else). "Save and run" enabled when `HasTemplate && !HasUnresolvedBindings && Name` non-blank. +7. **Success:** `Saved` event → the Recipes list reloads. +8. **Failure:** repository exception surfaces as `ErrorMessage`; leaving a dirty editor prompts a confirm dialog (`recipes.unsavedChanges.*`). +9. **Edge cases:** if a Mapping's template slide no longer exists in the presentation, `LoadMappingAsync` bails silently and the panels keep the previous Mapping's content; the Editor emits a **strictly 1:1** model (one placeholder × one column, one shape × one column) even though `TextInstruction`/`ImageInstruction` support N×N — a fallback chain in an imported Recipe is **lost on re-save**. + +### 5.2 Import / export a Recipe + +- **Import:** Recipes page → "Import" → pick `*.recipe` → `IRecipePackageService.ImportAsync` reads `Recipe.json`, extracts bundled `Workbooks/*` and `Presentations/*` into `%LOCALAPPDATA%\SlideGenerator\Imported\{Workbooks,Presentations}` (dedup by filename, Zip-Slip guarded, extension allow-list), rewrites the Recipe's paths to the extracted locations, and inserts a **new** Recipe row named after the file. A `null`/missing `mappings` is normalised to `[]`. +- **Export:** a row's "Export" → pick a save path → `ExportAsync` bundles the Recipe JSON (paths rewritten to bare filenames) plus every referenced workbook/presentation that still exists on disk into a `.recipe` zip. + +### 5.3 Run a Recipe + +1. **Start:** Recipes list row → "Run", or Guided step ④ → "Save and run". Opens the **Run dialog** (`RunDialogViewModel`, a transient instance). +2. **Intent:** generate output presentations now. +3. **Objects:** `Request`, `IService.PreviewAsync` / `CreateAsync`, `PlannedJob`. +4. **Actions:** set `Name` (pre-seeded `"{recipe} {yyyy-MM-dd HH-mm}"`), `OutputType`, `SaveFolder` (folder picker), optional `AllowLocalPaths` (currently a no-op — §3.9). Every field change re-runs `PreviewAsync`, which lists every `PlannedJob(OutputPath, WorkbookPath, WorksheetName, ConflictKind)`. +5. **State changes:** on "Start" → `CreateAsync` mints a `requestId`, persists the `RequestRecord`, publishes `RequestProgress(PreparationStarted)`, announces the expected Job count, then spawns each Job (`JobStatus.Pending`, persisted and flushed immediately). +6. **Validation (`CanStart`):** not previewing, ≥ 1 planned Job, **no conflicts**, non-blank name and folder. `CreateAsync` re-checks: throws if two of the Request's Jobs share an output path (`ConflictKind.DuplicateWithinRequest`), or if a Job's output path is already claimed by another active (Running/Pending/Paused) Request (`ConflictKind.ConflictsWithActiveRequest`). +7. **Success:** dialog closes with the new `requestId`; the shell navigates to Runs and preselects it. +8. **Failure:** any exception → `ErrorMessage` in the dialog; the dialog stays open. +9. **Edge cases:** multiple concurrent Runs of the *same* Recipe are allowed (guarded only at the output-path level, not the recipe level). + +### 5.4 Track and control a Run + +1. **Start:** Runs page. One list, chip-filtered (`RunStatusFilter`: All / Running / Paused / Done / Cancelled), searchable by name. Master–detail: request row + its Job rows + logs. +2. **Objects:** `RunsViewModel`, `RequestRunViewModel` per Request, `JobRunViewModel` per Job, `IProgressHub`. +3. **Live updates:** after the initial load, `IProgressHub` pushes coalesced `JobSnapshot` and `RowProgress` batches every 250 ms; existing rows are patched in place. A Request key never seen before triggers one full reload (a `JobSnapshot` alone lacks `Request.Name`/`CreatedAt`). +4. **Per-Request commands (there are no per-Job commands):** Pause (when Running), Resume (when Paused), Stop (when Running/Paused/Pending), Delete (confirm dialog; stops first if active), Open folder (opens the first Job's output directory in the OS file browser). +5. **Detail shows:** per-Job status, phase, `CurrentIndex`/`TotalRows` (determinate progress bar, or indeterminate if `TotalRows` is null), output path, a live "activity line" (`RowStage` + free-text `Note`, e.g. the URL being downloaded), and logs read on demand from the `.log` file. +6. **Success/failure:** aggregate Request status via `Service.DeriveStatus` — see §6.3. **There is no Request-level `Error` state** (see §16). + +### 5.5 Adjust settings + +Settings page → three groups (Appearance / Performance / Network) + a link to About. Every field **persists immediately on change** — no Save button, no debounce. Per-group "reset to defaults" (only the Performance reset command is wired in the ViewModel shown). Theme changes apply live; language changes apply live; `ReducedMotion` toggles the app's motion-duration resources. + +### 5.6 Check for updates / view About + +About page → "Check for updates" (`UpdateChecker` via Velopack/GitHub Releases; result one of `NotInstalled`/`UpToDate`/`UpdateDownloaded`/`Failed`). Developers and Supporters lists load once per session (24 h disk cache, fail-soft to empty). Repository and sponsor links open in the browser. + +--- + +## 6. State Machines / Lifecycle + +### 6.1 Job phase (within one running Job) + +``` +CreatingOutput ──► CreatingSlides ──► FillingText ──► FillingImages ──► Done + (copy template, (append 1 (per row: (per row: (terminal + strip slides; cloned slide read cells, resolve source, JobPhase + or reopen on per data row, compose text, download/local, stamped on + resume) Save each) Save each) crop, set the final + ImageData, JobSnapshot) + Save each) +``` + +- **Transitions:** automatic, forward-only, one direction. Driven by `SlideGenerationWorkload.RunAsync`. Each transition writes a durable `JobSnapshot` (flushed before proceeding) so a crash resumes from the correct phase. +- **Checkpoint:** `context.CheckpointAsync(ct)` + `ct.ThrowIfCancellationRequested()` **before every row**. Pause/cancel granularity is "between rows," never mid-row. +- `Queued` — never entered (§3.12). + +### 6.2 Job status + +``` + ┌────────── PauseAsync ─────────┐ + ▼ │ +Pending ──► Running ◄──────── ResumeAsync ────── Paused + │ │ │ │ + │ │ └──────── StopAsync ────────────┤ + │ │ │ + │ ├─► Complete (workload returned) │ + │ ├─► Error (workload threw) │ + └───────────┴─► Cancelled (cancellation observed) ◄─┘ +``` + +- **Pending → Running:** the engine picks the Job up (after acquiring a concurrency slot; a "starting" tick is published *before* the slot is acquired, so a queued Job still shows Running in the UI). +- **Running ⇄ Paused:** user, at the **Request** level (`IService.PauseAsync`/`ResumeAsync` fan out over the Request's Jobs). `GeneratorJobObserver` stamps `Paused`/`Running`. A paused Job blocks at its next checkpoint. +- **→ Cancelled:** user Stop, or app shutdown. The engine cancels the Job's `CancellationTokenSource` and also releases the pause gate so a paused Job unblocks to observe the cancellation. +- **→ Complete:** `RunAsync` returns a terminal `JobSnapshot(Complete, Done, TotalRows, TotalRows)`. +- **→ Error:** `RunAsync` threw (any exception in any phase, including a per-row exception — rows are **not** individually isolated). +- **Terminal states:** `Complete`, `Cancelled`, `Error`. `Paused` is *not* terminal. +- **Crash resume:** at startup `GeneratorResumeSource` reads every `Pending`/`Running`/`Paused` row and reschedules it from `(Phase, CurrentIndex)`, logging to the original Request's `.log` file. A previously `Paused` Job resumes as plain `Running` (no "why paused" is persisted). + +### 6.3 Request (Run) aggregate status — `Service.DeriveStatus` + +``` +any Job Running or Pending → Running +else any Job Paused → Paused +else all Jobs Cancelled → Cancelled +else → Complete ◄── an all-Error Request lands HERE +``` + +- **There is no `Error` branch.** A Request whose Jobs all ended `Error` reports `Complete`, appears in `ListCompletedAsync`, and `RunStatusFilter` has no "Errored" chip. `RequestRunViewModel.DeriveDisplayStatus` repeats the same omission client-side. Job-level `Error` *is* visible per-Job in the detail panel. **This is a real gap the Runs page must account for.** + +### 6.4 Request lifecycle phase — `RequestPhase` (progress only, never persisted) + +``` +PreparationStarted ──► ProcessingStarted ──► Completed + (spawn loop begins) (every announced Job (every announced Job + has left Pending) has reached a terminal status) +``` + +Monotonic. Published live by `Service.CreateAsync` (`PreparationStarted`) and derived elsewhere; `Summary.Phase` is recomputed statelessly from current Job statuses on every list call (`DeriveRequestPhase`). + +### 6.5 Recipe Editor mode + +``` +new Recipe ─► Guided (step Template → Data → Binding → Review) +existing ─► Advanced +Guided ──"Open Advanced mode"──► Advanced (one-way within a session; not remembered per recipe) +``` + +### 6.6 Binding display state + +`Unassigned` / `NeedsSelection` / `Suggested` → **`Assigned`** on user pick or confirmation ("touched"). Never regresses once touched. See §3.14. + +### 6.7 Theme + +`System → Light → Dark → System` cycle (toolbar toggle), or direct set from Settings. Persisted to `Setting.Appearance.Theme`; applied to `Application.RequestedThemeVariant` immediately. The toolbar toggle animates a circular reveal from the click point (falls back to instant when `ReducedMotion` is on or no window). + +--- + +## 7. Use Cases (capabilities catalogue) + +`IService` (`SlideGenerator.Generator`) is the single façade the Desktop app calls for generation. `IRecipeRepository` + `IRecipePackageService` for recipes. `ISettingManager` for settings. `ISummarizationService` for editor previews. + +| Use case | Input | Preconditions | Domain operation | Side effects | Output | Failure | +|---|---|---|---|---|---|---| +| **List recipes** | — | — | `IRecipeRepository.ListAsync` | SQLite read | `IReadOnlyList` (id/name/timestamps), newest-updated first | repo exception → `ErrorMessage` | +| **Get recipe** | `id` | recipe exists | `GetAsync` | read | `RecipeEntry` (full mappings) | throws if not found | +| **Create recipe** | `RecipeInput(Name, Recipe)` | — | `AddAsync` | insert | `IRecipeMetadata` | — | +| **Update recipe** | `id`, `RecipeInput` | recipe exists | `UpdateAsync` | update, bumps `UpdatedTimestamp` | `IRecipeMetadata` | throws if not found | +| **Delete recipe** | `id` | — | `DeleteAsync` | delete | `bool` (found?) | — | +| **Duplicate recipe** | `id` | recipe exists | Get + Add with `" (copy)"` name | insert | new metadata | — | +| **Export recipe** | `id`, `outputPath` | recipe exists | `RecipePackageService.ExportAsync` | writes a `.recipe` zip (+ bundled files) | — | — | +| **Import recipe** | `.recipe` path, target folders | valid archive | `ImportAsync` | extracts files to `Imported/`, inserts a new recipe | `IRecipeMetadata` | `InvalidDataException` on missing/invalid `Recipe.json`, Zip-Slip, etc. | +| **Preview a run** | `Request` | recipe exists | `IService.PreviewAsync` → `BuildJobs` + conflict scan | none | `IReadOnlyList` | throws if recipe missing | +| **Create a run** | `Request` | recipe exists; no output-path conflicts | `CreateAsync` → `BuildJobs`, persist `RequestRecord`, spawn N Jobs | inserts `Requests` row + N `Jobs` rows; starts N background tasks; creates the `.log` file; emits progress | `requestId` (string) | throws on duplicate/active-conflict output path, or missing recipe | +| **Pause / Resume / Stop a run** | `requestId` | — | fan out over eligible Jobs | Job status transitions, progress events | `PartialResult(Succeeded, Skipped)` | ineligible Jobs counted skipped, not failed | +| **Pause-all / Stop-all** | — | — | fan out over all active requests | as above | `int` (requests affected) | — | +| **List active / completed runs** | `includeLogs` | — | group `Jobs` by `RequestId`, filter by `DeriveStatus`, join `Requests`, optionally parse `.log` | reads (+ `.log` parse if `includeLogs`) | `IReadOnlyDictionary` | — | +| **Delete a run** | `requestId` | — | stop if active, then delete Job + Request rows | deletes rows (not the `.log`, not output files) | `bool` (found?) | — | +| **Delete all completed runs** | — | — | loop delete | as above | `int` | — | +| **Initialize** (startup) | — | called once before anything else | `JobRunner.InitializeAsync` → resume non-terminal Jobs | reschedules crash-leftover Jobs | — | — | +| **Shutdown** | — | called at app exit | cancel running Jobs, wait, final flush | — | — | — | +| **Summarize workbook** | `WorkbookIdentifier`, `getPreview` | file exists | open read-only, read headers + up to 20 rows/sheet | Syncfusion read | `WorkbookSummary` | `FileNotFoundException` | +| **Summarize presentation** | `PresentationIdentifier`, `getPreview` | file exists | open read-only, scan placeholders + image shapes + thumbnails | Syncfusion read | `PresentationSummary` | `FileNotFoundException` | +| **Get / Update / Reset settings** | `Setting` | — | `ISettingManager` | writes `UserSettings.json` | `Setting` | write error rethrown | +| **Check for updates** | — | installed as a Velopack app | `UpdateChecker` → GitHub Releases | may download an update package | `UpdateCheckResult` | `Failed` on any network error | +| **Get contributors / supporters** | — | — | `AboutDataService` → GitHub API / raw JSON | 24 h disk cache write | lists (possibly empty) | never throws — empty on failure | + +--- + +## 8. Recipe (dedicated section) + +**What it represents in V2:** a persisted, reusable, named configuration that maps template placeholders and picture shapes to worksheet columns, across one or more template slides. It is *pure configuration* — it holds no data, no rendered output, no run history, and no live handle to any file. It is the thing a user authors once and runs many times. + +**Structure:** `Recipe = flat list of Mapping`. There is **no graph, no `Node`, no `Edge`, no id-based cross-references** — that model existed in an earlier iteration and was deliberately removed (`plans/status.md`, `CLAUDE.md`). A `Mapping = one template slide + its text rules + its image rules + one-or-more worksheet sources`. + +**How created:** (a) "New" in the Recipes page → an empty `Recipe([])` opened in the Guided editor; (b) `IRecipePackageService.ImportAsync` from a `.recipe` file → a new row with bundled files copied locally; (c) "Duplicate" of an existing row. + +**How edited:** in the inline Recipe Editor (Guided or Advanced). The Editor loads previews via `ISummarizationService`/`ISummaryCache`, computes binding suggestions (`BindingMatcher`), and on Save projects its panels back into a `Recipe` (`RecipeEditorViewModel.ToRecipe`) written via `IRecipeRepository.Add/Update`. + +**How stored:** one row in the `Recipes` table of `%LOCALAPPDATA%\SlideGenerator\Data\Data.db`: `Id` (autoincrement int), `Name` (text), `Recipe` (the whole object as camelCase JSON, `RecipePackageFormat.Data.Recipe.Format` — `JsonStringEnumConverter`, `WhenWritingNull`), `CreatedTimestamp`, `UpdatedTimestamp` (ISO-8601 UTC strings). No normalised child tables. + +**What it contains:** `Mappings` only. Each Mapping contains `Sources` (worksheet feeds with optional column subset + row filter), `Template` (`PresentationSource` = file path + 1-based slide index), `TextInstructions` (placeholder-set → column-list), `ImageInstructions` (shape-set → column-list + crop chain + fallback image). + +**What it references (never owns):** absolute (or relative) paths to workbook files and presentation files. These files live wherever the user put them (or, for imports, in `Imported/`). The Recipe does not track their existence, hash, or modification. + +**Multiple templates per Recipe:** **YES.** A Recipe has N Mappings; each Mapping's `Template` is an independent `(file, slideIndex)` pair. Different Mappings may point at different presentations, the same presentation with different slides, or (legally, though pointless) the same slide. There is **no** "Recipe → exactly one Template" constraint anywhere in the code. + +**Template ownership:** templates are **not owned by Recipes** and are **not managed entities**. They are file paths. The same `.pptx` can be referenced by any number of Recipes and any number of Mappings. Selecting a template = the template-picker dialog (pick a presentation file → pick a slide). + +**How a Recipe participates in generation:** at Run time `Service.BuildJobs` flattens `Mappings.SelectMany(m => m.Sources.Select(...))` into one fully-resolved `JobSpecification` per (Mapping × Source). From that moment the Recipe is irrelevant to the Run — every value has been copied into the `JobSpecification` and persisted on the Job row. + +**What happens when a Recipe changes:** nothing happens to existing Runs (they carry snapshots). Future Runs use the new definition. Editing/deleting a Recipe while its Runs execute is explicitly safe. + +**Validation rules:** +- Repository: none beyond "row exists" for Get/Update/Delete. +- Load: `Mappings` coerced non-`null`. +- Editor Save gate: `IsDirty && Name` non-blank. +- Editor "Save and run" gate: `HasTemplate (≥1 Mapping) && no NeedsSelection bindings && Name` non-blank. +- **A Recipe with zero Mappings, unresolved bindings, or dangling file references can be saved.** It just cannot be run cleanly. + +--- + +## 9. Template (dedicated section) + +**What a Template represents:** a `PresentationSource(PresentationIdentifier Presentation, SlideIdentifier Slide)` — i.e. *a PowerPoint file on disk plus a 1-based slide index into it*. That is the entire concept. There is no `Template` class, no template library, no template metadata store. + +**Lifecycle:** none of its own. The file is external and user-managed. The reference is created when the user picks it in the template picker and lives inside a `Mapping` for as long as that Mapping exists. + +**Relationship with Recipes:** a Mapping *references* one template. A Recipe (via its Mappings) references 0..N templates. Many Recipes and many Mappings may reference the same template file; the file has no idea it is being used. + +**Relationship with generated slides:** in phase A the Job **copies the template file** to the output path and **removes all of its slides**. In phase B it opens the template read-only, `Clone()`s the requested slide, and `AddSlide()`s one clone per selected data row (the source presentation is kept open the whole time because Syncfusion clones stay coupled to their source's layout/master). So every output slide is a structural clone of the one template slide. + +**Configuration:** the template itself is not configured. What *is* configured against it: `TextInstruction`s keyed by the Mustache tags found in its shapes' text, and `ImageInstruction`s keyed by the names of its picture shapes. `TemplateSlideIndex` selects which slide. + +**Source / assets:** the `.pptx/.potx/.ppsx` file. Supported types: `PresentationType { Potx, Pptx, Ppsx }`. On `.recipe` export the file is bundled under `Presentations/`; on import it is extracted to `Imported\Presentations\`. + +**How it is selected:** `TemplatePickerViewModel` — pick a presentation file (file picker), then pick one of its slides (thumbnail list from `SummarizePresentationAsync`). + +**Reusability:** fully reusable. One template file, or one slide within it, can back any number of Mappings across any number of Recipes. Nothing is copied into the Recipe except the path and index. + +**What makes a slide usable as a template (from `SummarizationService`):** +- **Text binding targets** = distinct Mustache tags (`{{tag}}`, `{{#tag}}`, `{{{tag}}}`, …) scanned from every shape's display text. A shape with no `{{…}}` contributes no placeholders. +- **Image binding targets** = shapes where `IShape.ImageData != null` — i.e. shapes that **already contain a picture** in the template (a placeholder image the user drops in). An empty rectangle or a content placeholder with no image is **not** offered as an image target in the Editor. + +--- + +## 10. Studio & Generation + +### 10.1 "Studio" + +**There is no Studio in V2.** `ShellDestination` has exactly four values — `Recipes`, `Runs`, `Settings`, `About` — and none is a "Studio." `plans/status.md` lists "3-tab Studio" explicitly among *deliberately rejected* design options. + +The closest thing is the **Recipe Editor**, which is an *inline state of the Recipes page* (`RecipesViewModel.IsEditorOpen` / `.Editor`), not a navigable destination. It is where the user configures a Recipe (§5.1, §8). It is not a slide editor — it never modifies the template file; it edits binding rules, previewed against read-only summaries. + +### 10.2 What "Generate" triggers + +"Start" in the Run dialog → `IService.CreateAsync(Request)`. Synchronously it: + +1. resolves the Recipe, mints `requestId` (`Guid.NewGuid().ToString()`), +2. computes the Job list (`BuildJobs`), +3. rejects duplicate / active-conflicting output paths, +4. writes the `RequestRecord` (`Requests` table), publishes `RequestProgress(PreparationStarted)`, announces the expected Job count, +5. loops: for each Job, persist its initial `Pending` `JobSnapshot` (flushed immediately) and hand it to the Job engine, which fires it on `Task.Run` and returns without waiting. + +`CreateAsync` returns the `requestId` almost immediately. Generation runs entirely in the background. + +### 10.3 What runs internally per Job (`SlideGenerationWorkload.RunAsync`) + +- **Phase A — CreatingOutput:** if fresh, `PreflightCleanup` deletes any prior file at this Job's own output path, `File.Copy` the template → output path, remove every slide. If resuming, reopen the existing output as-is. Open the source workbook read-only, resolve the worksheet, compute `dataRows` from the `RowFilter`, load + clone the template slide. +- **Phase B — CreatingSlides:** append `dataRows.Count` clones of the template slide, `output.Save()` after each; `JobSnapshot(CreatingSlides, i+1)` per slide. +- **Phase C — FillingText:** per row — read the row's cells, `BuildRowTextValues` (per `TextInstruction`: first non-empty column value → all its placeholders), `TextComposer.Compose(shape, values)` for every shape on the row's slide (`ITextComposer` renders Mustache while preserving each text run's formatting via coverage-ratio distribution), `output.Save()`; `RowProgress` + `JobSnapshot` per row. +- **Phase D — FillingImages:** first inspect every distinct image source across all rows once (`ICloudClient.InspectAsync` → `ContentInfo`, deduped, retried with backoff per `Setting.Network.Retry`). Then per row, per `ImageInstruction`, per target shape: + - resolve the source cell (first non-empty of `Columns`); + - if it is an existing local file → load + crop in memory; + - else if it inspected to an image → ensure it is in the per-job download cache (`%TEMP%\SlideGenerator\{requestId}\{jobId}\{hash(uri)}{ext}`), respecting `Setting.Network.MaxDownloadBytes`; then crop; + - else if `FallbackImagePath` exists → load + crop it; + - crop = `ISmartCropper.CropAsync(image, shapeBounds, RoiOptions)` — try each `RoiOption` in order, first success wins, else centre crop; + - assign the PNG bytes to `IShape.ImageData`; `output.Save()` after the row. +- **Completion:** `RunAsync` returns `JobSnapshot(Complete, Done, TotalRows, TotalRows)`. + +### 10.4 Progress & errors + +- **Progress:** `RequestProgress` (aggregate phase), `JobSnapshot` (per-Job current state), `RowProgress` (per-row: `RowStatus`, `RowStage`, free-text `Note`). Published on the in-process `IEventBus`; the Desktop's `ProgressHub` coalesces and marshals them to the UI thread every 250 ms. Only `Jobs` is persisted (buffered ~1 s); rows are live-only. +- **Errors:** an exception anywhere in `RunAsync` (missing worksheet, missing template slide, Syncfusion failure, an unhandled per-row error) fails the whole Job → `JobStatus.Error`. Rows are **not** individually try/caught to continue. *Image-specific* soft failures do not fail the Job: a failed download returns `null` (→ fallback image, else the shape is left unfilled); a crop exception is caught and logged (`CropToPngAsync`) and the shape is left unfilled. +- **Outputs:** one `.pptx/.potx/.ppsx` per Job at its `OutputPath`. Files are never auto-deleted; the user opens the folder from the Runs detail panel. + +### 10.5 Synchronous or asynchronous + +**Asynchronous.** `CreateAsync` returns immediately; Jobs run on background tasks bounded by `MaxConcurrentJobs`; the UI observes them through the progress stream and periodic list reloads. There is a genuine Run/Job/execution model — see §11. + +--- + +## 11. Runs / Execution + +V2 has a full execution model. Vocabulary: + +| Code term | Meaning | +|---|---| +| **Request** (a.k.a. **Run**) | one user-initiated generation of a Recipe. Identified by `requestId` (GUID string). Persisted as `RequestRecord` in the `Requests` table (write-once). Groups N Jobs. | +| **Job** | one unit of generation = one Mapping × one WorksheetSource = one output file. Identified within its Request by a 0-based ordinal `JobId` (int). Persisted as a `JobSnapshot` row in the `Jobs` table (current-state, updated as it progresses). | +| **JobSpecification** | the fully-resolved, self-contained description of what a Job does (workbook, worksheet, columns, row filter, template path + slide index, text/image instructions, output path). | +| **Job Engine** (`SlideGenerator.Jobs`) | a generic, domain-free scheduler: `IJobEngine` runs any `IJobWorkload`, owning the running-job registry, the concurrency semaphore, pause/checkpoint, and crash-resume. Zero knowledge of slides. | +| **Job Workload** (`SlideGenerationWorkload`) | the slide-specific 4-phase pipeline, plugged into the engine. Wrapped in `LoggingWorkload` (per-Job file-log scope). | +| **JobRunner** | thin adapter from `IService`/`Service` vocabulary onto `IJobEngine`. | + +**Why it exists:** generation is long-running (per-row, with network I/O), needs bounded concurrency to cap RAM, needs pause/resume, and must survive an app crash mid-generation. The Engine/Workload split makes the concurrency/resume machinery reusable for future non-slide job types. + +**Lifecycle & states:** see §6.2 (Job status), §6.1 (Job phase), §6.3–6.4 (Request aggregate). + +**What creates a Run:** only `IService.CreateAsync`, from the Run dialog or Guided "Save and run." + +**What a Run references:** a `RecipeId` (informational after spawn), a `SaveFolder`, an `OutputType`, a `Name`, one shared `.log` path, and its Jobs. + +**Information a Run/Job carries:** everything in §3.9–3.12. Notably a Job carries its **whole** `JobSpecification` on its row — no lookup needed to run or resume. + +**Progress tracking:** live via the event bus / `ProgressHub` (§10.4). Persisted state is coarse: `(JobStatus, JobPhase, CurrentIndex, TotalRows)` per Job, buffered to SQLite ~1×/second. **Per-row history is never persisted** — once a row's `RowProgress` event has been shown, it is gone; `JobSummary` has no `Rows` field. + +**Completion:** a Job reaching `Complete`/`Cancelled`/`Error`. A Request is "complete" when `DeriveStatus` no longer returns `Running`/`Paused` (note the missing `Error` case, §16). `Summary.CompletedAt` = the latest Job timestamp once the Request is done. + +**Failure:** per-Job `Error` (visible in the Job detail). No aggregate error state. A Cancelled Job leaves a partially-written output file on disk. + +**Inspecting past executions:** yes — `ListCompletedAsync` returns `Summary` per completed/cancelled Request, browsable on the Runs page (name, status, created/completed times, per-Job phase/index/output path, and logs parsed from the `.log` file on demand). The Recipes page also shows a per-recipe "recent runs" strip (up to 5, immutable snapshots). + +**Persistence vs transience:** +- Persistent: `Requests` rows, `Jobs` rows (current state only), the `.log` file, the output `.pptx` files. +- Transient: everything about rows (progress, activity line), the per-job download cache (deleted on terminal), `RequestPhase`, the in-memory running-job registry, `Summary` objects (rebuilt every list call), parsed log entries (never cached). + +--- + +## 12. Settings & Configuration + +**Root:** `Setting` (immutable record), exposed via `ISettingProvider.Current` / `ISettingManager`. Persisted to `%LOCALAPPDATA%\SlideGenerator\Data\UserSettings.json` under a top-level `"Application"` key, pretty-printed JSON. Single global instance (no per-recipe, per-run, or per-window scope). On load failure or missing file → defaults are written. + +### 12.1 Appearance (`Setting.AppearanceSetting`) — **UI only, does not affect generation** + +| Field | Type | Default | Valid values | Effect | +|---|---|---|---|---| +| `Theme` | `ThemeMode` enum | `System` | `System`, `Light`, `Dark` | `Application.RequestedThemeVariant`, applied live | +| `Language` | `string` | `""` | culture name (`"vi"`, `"en"`, …) or `""` = follow OS UI culture | `LocalizationService` reloads strings live (no restart) | +| `ReducedMotion` | `bool` | `false` | — | zeroes the app's `MotionMicro`/`MotionUi`/`MotionBrand` duration resources → animations become instant; theme reveal falls back to instant | + +### 12.2 Performance (`Setting.PerformanceSetting`) — **affects generation** + +| Field | Type | Default | Effect | +|---|---|---|---| +| `MaxConcurrentJobs` | `uint` | `5` | size of the Job engine's concurrency semaphore. Caps how many Jobs run their workload simultaneously (RAM guard — each holds a Workbook + Presentation in memory). Applies to the **next** Job spawned (read fresh per property access); running Jobs keep their slot. Only throttles execution, never Run acceptance. | + +This is the **only** field on `PerformanceSetting` — the old parallel-download/edit/read fields and the hardware/network calibration system were removed. + +### 12.3 Network (`Setting.NetworkSetting`) — **affects generation (image download only)** + +| Field | Type | Default | Effect | +|---|---|---|---| +| `Proxy.UseProxy` | `bool` | `false` | whether the download `HttpClient` uses a proxy (`Registration` `ConfigurePrimaryHttpMessageHandler`) | +| `Proxy.ProxyAddress` | `string` | `""` | e.g. `http://proxy:8080` | +| `Proxy.Username` / `Password` / `Domain` | `string` | `""` | `NetworkCredential` for the proxy | +| `Retry.MaxRetries` | `int` | `3` | retry count for `ICloudClient.InspectAsync` / `DownloadAsync` (exponential backoff) | +| `Retry.Timeout` | `int` (seconds) | `30` | network timeout | +| `Retry.MaxRetryDelay` | `int` (seconds) | `16` | backoff ceiling | +| `MaxDownloadBytes` | `uint` (bytes) | `52428800` (50 MB) | per-file download cap; `0` = unlimited. A source exceeding it is skipped (logged), the shape gets the fallback image or nothing. Surfaced in the UI as megabytes. | + +### 12.4 Behaviour + +- **Immediate persistence:** every field writes to disk on change (`SettingsViewModel.Persist` → `ISettingManager.Update` → `Save`). No Save button, no debounce, no dirty state. +- **Reset:** `ISettingManager.ResetToDefaults()` exists (whole-tree). The Settings page exposes a per-group reset; only the **Performance** group's reset command is wired in the ViewModel shown (`ResetPerformanceAsync` → `Performance = new PerformanceSetting()`). +- **Validation:** none in the model or manager. Fields are bound directly (numeric up-downs, text boxes). Out-of-range values are not rejected. +- **UI-preference vs generation-config split:** the code distinguishes them by group. Appearance = UI only. Performance + Network = generation. There is no separate "UI preferences" store — it is all one `Setting` tree. +- **Not stored anywhere:** window size/position, last-opened page, per-recipe "prefer Advanced mode," column widths, recent folders. + +--- + +## 13. File System & Offline Model + +**Local-first for domain data; the network is used and load-bearing for three specific things.** Do not describe the app as "offline" — it is not. + +### 13.1 What the app reads + +| What | From | When | +|---|---|---| +| Source workbooks (`.xls/.xlsx/.xltx/.ods/.csv/.tsv`) | user-picked absolute paths (or `Imported\Workbooks\` for imports) | Editor preview; every Job, phase A/C/D | +| Template presentations (`.pptx/.potx/.ppsx`) | user-picked absolute paths (or `Imported\Presentations\`) | Editor preview; every Job, phase A/B | +| `.recipe` package | user-picked path | on import | +| `Data.db` | `%LOCALAPPDATA%\SlideGenerator\Data\Data.db` | continuously (recipes, requests, jobs) | +| `UserSettings.json` | same folder | at startup | +| `appsettings.json` | executable directory | at startup (log levels) | +| About caches (`about-contributors.json`, `about-sponsors.json`) | `Data\` | About page, 24 h TTL | +| Local image files referenced in data cells | wherever the cell points | phase D (`File.Exists(source)`) | +| Fallback images | `ImageInstruction.FallbackImagePath` | phase D | + +### 13.2 What the app writes + +| What | To | +|---|---| +| Output presentations | `{Request.SaveFolder}\{workbookStem}\{worksheetName}{ext}` — one per Job, incrementally saved after every row | +| `Data.db` | `%LOCALAPPDATA%\SlideGenerator\Data\` (SQLite, `journal_mode=WAL`) | +| `UserSettings.json` | same folder | +| Per-Run log | `%LOCALAPPDATA%\SlideGenerator\Logs\Workflows\{sanitizedRunName}.log` (one file shared by all Jobs of the Run) | +| System logs | `Logs\System\{timestamp}.log` (+ a `latest.log` hard link) | +| Per-Job download cache | `%TEMP%\SlideGenerator\{requestId}\{jobId}\{hash(uri)}{ext}` — deleted when the Job reaches a terminal (non-Paused) state | +| Imported recipe resources | `Imported\Workbooks\`, `Imported\Presentations\` | +| Update packages | Velopack's own location (on "download update") | +| Single-instance lock | `Instance.pid` under the user data root | + +### 13.3 Path resolution + +- User data root = `%LOCALAPPDATA%\SlideGenerator`, or the executable directory if built `-p:Portable=true` (`NameAndPaths.Portable`, a compile-time constant). +- All user-supplied paths pass through `Path.GetFullPath` at entry (CodeQL path-injection sanitiser). Rooted paths in identifiers are normalised; relative paths are kept relative. + +### 13.4 Networking & external services (all optional to the app's core, required for their feature) + +| Feature | Endpoint / mechanism | +|---|---| +| Row image download | arbitrary HTTP(S) URLs; HEAD→GET redirect following; `GoogleDriveResolver` rewrites Drive share links to direct-download URLs (**only** Google Drive is implemented; OneDrive/SharePoint are not) | +| App updates | Velopack against **GitHub Releases** for `thnhmai06/SlideGenerator` | +| About — developers | `https://api.github.com/repos/thnhmai06/SlideGenerator/contributors` | +| About — supporters | `https://raw.githubusercontent.com/thnhmai06/SlideGenerator/data/sponsors.json` | + +There is **no local server**, no background service, no telemetry endpoint. If the network is unavailable: recipes/runs/settings/editor all work fully; row images fall back to the fallback image or are left blank; About lists show empty; update check reports `Failed`. + +--- + +## 14. Persistence + +### 14.1 The single database — `Data.db` + +One SQLite file, `%LOCALAPPDATA%\SlideGenerator\Data\Data.db`, `journal_mode=WAL`. Replaces an older per-purpose split. Schema is created and migrated by **DbUp** at startup (`DatabaseMigrator.Migrate`, called from `Program.Main` before the host is built), from embedded scripts `001_2.0.0.sql` and `002_add-total-rows-to-jobs.sql`, tracked in DbUp's `SchemaVersions` table. Three domain tables: + +- **`Recipes`** — `Id` (INTEGER PK AUTOINCREMENT), `Name` (TEXT), `Recipe` (TEXT — the whole `Recipe` object as JSON), `CreatedTimestamp`, `UpdatedTimestamp` (TEXT, ISO-8601 UTC). Changes rarely (only on Editor save / import / duplicate / delete). Short-lived connection per CRUD call. +- **`Requests`** — `RequestId` (TEXT PK), `RecipeId` (INTEGER), `Name`, `OutputType`, `SaveFolder`, `AllowLocalPaths` (INTEGER), `LogPath`, `CreatedAt`. **Write-once** — inserted by `CreateAsync`, never updated. Deleted with its Run. +- **`Jobs`** — composite PK `(RequestId, JobId)`. Columns: `Status`, `Phase`, `CurrentIndex`, `TotalRows` (the mutable current state); `WorkbookPath`, `WorksheetName`, `UsedColumnsJson` (nullable JSON), `RowFilterType` + `RowFilterStart`/`End`/`PartitionIndex`/`PartitionCount` (nullable scalars — a small closed set, no JSON), `TemplatePresentationPath`, `TemplateSlideIndex`, `TextInstructionsJson`, `ImageInstructionsJson`, `OutputPath` (the resolved spec — written once), `Timestamp`. The `UPSERT` only updates `Status`/`Phase`/`CurrentIndex`/`OutputPath`/`Timestamp`/`TotalRows` on conflict; the spec columns are immutable after insert. +- **No `Rows` table** — per-row progress is never persisted. + +`Jobs` is the only hot table. `JobsRepository : BufferedRepository<(string,int), JobSnapshot>` — writers `Enqueue` (coalesced, last-write-wins per key); a background `PeriodicTimer` (~1 s) drains the dirty set and upserts the batch in one transaction, then raises `Flushed`. Certain writes force an immediate flush (initial `Pending`, phase transitions, terminal states) so a crash resumes from the right point. + +### 14.2 Settings + +`UserSettings.json` (see §12) — plain `System.Text.Json`, indented, under an `"Application"` object. + +### 14.3 Serialization formats + +- Recipe (DB + `.recipe`): camelCase JSON, `JsonStringEnumConverter`, ignore-null, a custom `IReadOnlySet` converter. `RowFilter` polymorphism via a `"mode"` discriminator; `RoiOption` polymorphism via `Mode`. +- Job spec `*Json` columns: a separate small `JsonSerializerOptions` (`JobSpecificationJson`). +- Settings: default STJ, indented. +- Logs: line-oriented text with a parseable scope path (`{requestId}` / `{requestId}/{jobId}` / `{requestId}/{jobId}/{rowIndex}`), read back by a regex `LogFileReader`. + +### 14.4 Persistent vs transient objects + +| Persistent | Transient (rebuilt/lost) | +|---|---| +| `RecipeEntry` (row) | `Recipe` object graphs held in the Editor | +| `RequestRecord` (row) | `Summary` / `JobSummary` (recomputed every list call) | +| `JobSnapshot` current state (row) | `RowProgress`, activity line, per-row anything | +| Output `.pptx` files | in-memory running-Job registry, `PauseGate` | +| Per-Run `.log` file | per-Job download cache (deleted on terminal) | +| — | `RequestPhase` aggregation, `MappingEditSession` touched-sets | + +### 14.5 Migration / versioning + +DbUp forward-only migrations, embedded, auto-discovered by wildcard in the `.csproj`, tracked in `SchemaVersions`. No down-migrations. There is no schema version stamped in the Recipe JSON. + +--- + +## 15. Events & Messaging + +All in-process (no message broker, no IPC). Two publish-only interfaces in `SlideGenerator.Generator` — `IEventBus`, `ILogNotifier` — implemented in the Desktop host by `GeneratingEventBus` / `LogNotifier`, consumed by `ProgressHub` and ViewModels. + +| Event | Payload | Emitted when / by | Consumed by | Domain fact or UI signal? | +|---|---|---|---|---| +| `RequestProgress` | `RequestId`, `RequestPhase` (`PreparationStarted`/`ProcessingStarted`/`Completed`), `Timestamp` | `Service.CreateAsync` (`PreparationStarted`); other phases inferred | `ProgressHub.RequestProgressChanged` → shell/ViewModels | UI signal — never persisted | +| `AnnounceExpectedJobCount(requestId, count)` | request id + N | `Service.CreateAsync` before the spawn loop | phase-aggregation logic | internal timing signal | +| `JobSnapshot` (published as job progress) | the whole job current-state row | `GeneratorJobObserver` on every progress/pause/resume/terminal transition; and each phase transition | `ProgressHub.Jobs` (coalesced by `(requestId,jobId)`, 250 ms) → `RunsViewModel` patches rows; `MainWindowViewModel` counts active jobs for the window title | **Domain fact** — also the persisted row | +| `RowProgress` | `RequestId`, `JobId`, `RowIndex` (1-based), `RowStatus` (`Waiting`/`Processing`/`Done`/`Error` — **only `Processing` and `Done` are ever emitted**), `RowStage` (`None`/`Downloading`/`CroppingImage`/`SavingOutput` — **`CroppingImage` is never emitted**), `Note`, `Timestamp` | `SlideGenerationWorkload.ReportRow` — one call per row per stage | `ProgressHub.Rows` (coalesced by `(requestId,jobId,rowIndex)`) → the Job detail "activity line" | UI signal — never persisted, no history | +| `LogNotification` / `LogEntry` | timestamp, scope path, level, message | every log line written during a Job (`ScopeNotifyingSink`) | `ProgressHub.Logs` (append-only, never dropped) + the per-Run `.log` file | diagnostic — persisted only to the `.log` file | +| `IJobsRepository.Flushed` | the batch just persisted | after each ~1 s SQLite flush | — (was relayed over IPC in V1; now unused by the Desktop UI, which reads the event bus directly) | internal | + +**ViewModel-level events** (not a bus): `RecipesViewModel.RunStarted` → shell navigates to Runs; `RecipeListItemViewModel.EditRequested`/`RunStarted`/`Deleted`/`Duplicated`; editor `Saved`/`RunStarted`; child-panel `Changed` → dirty tracking; `RunDialogViewModel.RequestClose`; `TemplatePickerViewModel.RequestClose`; `LocalizationService.PropertyChanged` (language switch). + +**Ordering constraint the UI must respect:** `ProgressHub` must be constructed (and its subscriptions attached) **before** `IService.InitializeAsync()`, because crash-resumed Jobs start emitting immediately and a late subscriber would miss their first events (`App.axaml.cs` enforces this). + +--- + +## 16. Validation & Errors + +### 16.1 What can fail, where it is checked, how it surfaces + +| Failure | Where checked | Representation | Recoverable? | Domain state left behind | +|---|---|---|---|---| +| Recipe not found (Get/Update/Delete/Run) | `SqliteRecipeRepository`, `Service` | `InvalidOperationException` | user retries | none | +| Corrupt Recipe JSON in DB | `DbReadEntry` | swallowed → `Recipe([])` | — | recipe silently reads as empty | +| Invalid / null `mappings` on import | `ReadRecipeFile` | coerced to `[]` (or `InvalidDataException` if `Recipe.json` missing/not JSON) | user re-exports | none | +| `.recipe` Zip-Slip / bad extension / escaping entry | `ExtractEntry` | `InvalidDataException` (escape) or silent skip (extension) | — | partial extraction possible | +| Blank `SaveFolder` | `Request` ctor | `ArgumentException` | user picks a folder | none | +| Output-path duplicate within a Run | `Service.CreateAsync` / `PreviewAsync` | `ConflictKind.DuplicateWithinRequest`; `CreateAsync` throws `InvalidOperationException` | user changes folder / recipe | no Run created | +| Output-path collides with an active Run | same | `ConflictKind.ConflictsWithActiveRequest`; `CreateAsync` throws | user waits / changes folder | no Run created | +| Worksheet / sheet name not found | `SlideGenerationWorkload` phase A | exception → `JobStatus.Error` | resume won't help (config wrong); user fixes recipe + re-runs | partial output file on disk | +| Template slide index out of range | phase A | exception → `JobStatus.Error` | as above | partial output file | +| Syncfusion licence missing | Document layer | exception at first workbook/presentation open → `JobStatus.Error` | set the licence, re-run | none | +| Image URL unreachable / over size cap | phase D | soft — `null` → fallback image → shape left unfilled; logged | — | Job continues, `Complete` | +| Crop failure | `CropToPngAsync` | soft — caught, logged, shape left unfilled | — | Job continues | +| Any other per-row exception | phase B/C/D loop | **hard** — propagates → `JobStatus.Error` | resume from `(Phase, CurrentIndex)` after fixing cause | partial output file | +| App crash mid-Job | — | Job row stays `Pending`/`Running`/`Paused` | **auto-resumed** at next startup from `(Phase, CurrentIndex)` | output file kept and reopened | +| Settings file unreadable | `SettingManager.Load` | logged → defaults written | — | settings reset to defaults | +| Network failure on About / update | `AboutDataService` / `UpdateChecker` | empty lists / `UpdateCheckResult.Failed` — never an exception to the UI | user retries | none | + +### 16.2 The gaps a designer must know + +1. **No Request-level `Error` status** (§6.3). An all-failed Run displays as `Complete` and sits in the "Done" filter. Job-level `Error` is only visible by expanding the Run's Jobs. +2. **`RowStatus.Error` and `RowStatus.Waiting` are never emitted**; `RowStage.CroppingImage` is never emitted; `JobPhase.Queued` is never entered. Do not design chips/indicators for these four. +3. **Rows are not error-isolated.** One bad row (structurally) kills its whole Job. Only *image* problems degrade gracefully within a row. +4. **`AllowLocalPaths` does nothing** (§3.9). +5. **No pre-run validation that referenced files exist.** A recipe pointing at a moved workbook saves fine and only fails when its Job hits phase A. +6. **The Editor can save an incomplete/invalid recipe** (§8). Only "Save and run" is gated. +7. **Re-saving an imported recipe with N-column fallback chains flattens them to 1** (§5.1). + +--- + +## 17. Architecture & Domain Boundary + +``` +┌───────────────────────────────────────────────────────────────┐ +│ Host / UI — SlideGenerator.Desktop (Avalonia, MVVM, │ +│ CommunityToolkit.Mvvm) │ +│ Shell (4 destinations) · Features/{Recipes,Runs,RecipeEditor,│ +│ Settings,About} · Services/{Progress,Theme,Localization, │ +│ Dialogs} · Bootstrap/{SingleInstanceLock,UpdateChecker, │ +│ Metadata} · Program.cs (entry, DB migration, Velopack) │ +└───────────────▲───────────────────────────────────────────────┘ + │ builds the generic Host / DI container; wires everything in-process +┌───────────────┴───────────────────────────────────────────────┐ +│ Application │ +│ SlideGenerator.Generator — IService façade, Service, │ +│ JobRunner, SlideGenerationWorkload, GeneratorJobObserver, │ +│ GeneratorResumeSource, JobsRepository/RequestsRepository, │ +│ Progress DTOs, IEventBus/ILogNotifier (publish-only) │ +│ SlideGenerator.Summarizer — ISummarizationService │ +└───────────────▲───────────────────────────────────────────────┘ +┌───────────────┴───────────────────────────────────────────────┐ +│ Domain │ +│ SlideGenerator.Settings — Setting tree, ISettingManager, │ +│ NameAndPaths (all paths), DatabaseMigrator (DbUp) │ +│ SlideGenerator.Recipe — Recipe/Mapping/Instruction models, │ +│ IRecipeRepository (SQLite), IRecipePackageService (.recipe) │ +└───────────────▲───────────────────────────────────────────────┘ +┌───────────────┴───────────────────────────────────────────────┐ +│ Foundation │ +│ Utilities · Cloud (ICloudClient, Google Drive resolver) · │ +│ Logging (Serilog, scoped file logging) · │ +│ Document (Syncfusion Excel/PPT wrappers + Mustache engine) · │ +│ Image (NetVips load, SmartCropper, YuNet face detection) · │ +│ Jobs (generic IJobEngine — zero project refs) │ +└───────────────────────────────────────────────────────────────┘ +``` + +**Responsibilities:** +- **Domain layer** owns the *definitions*: what a Recipe is, how it is stored, how a `.recipe` is packaged; what a Setting is and where it lives. +- **Application layer** owns *execution*: turning a Recipe + a Run request into Jobs, running the 4-phase pipeline, tracking/persisting Job state, resuming after a crash, exposing the `IService` façade. `SlideGenerator.Jobs` owns *generic* execution mechanics (concurrency, checkpoint, resume) with no domain knowledge. +- **Foundation** owns *capabilities*: document I/O, templating, image processing, HTTP/cloud, logging. +- **Host/UI** owns *presentation and process lifecycle*: the window, navigation, ViewModels, the in-process event bus implementations, DI wiring, startup (single-instance, DB migration, Velopack, splash), shutdown. +- **Dependencies flow strictly downward.** Each module has a `Registration.cs` DI entry point. `SlideGenerator.Jobs` has no project references at all. + +**On disk but not in the solution (orphans — treat as non-existent):** `src\SlideGenerator.Coordinator\`, `tests\SlideGenerator.Acquisition.Tests\`. Neither is in `SlideGenerator.slnx` or `git ls-files`. "Coordinator" is not a live V2 concept. + +**Rendering / MVVM specifics for the designer:** +- One `MainWindow`, content-swapped between `SplashViewModel` (only if startup exceeds ~400 ms) and `ShellViewModel`. +- `ShellViewModel` holds exactly four page ViewModels, resolved lazily and cached; `CurrentPage` points at one. No `INavigationService`. +- Recipes/Runs sit in a title-toolbar nav pill; Settings/About are separate icon buttons. +- Localization: every string is an i18n key (`recipes.recipe.name` style); live language switch works via a converter keyed off a `Revision` counter (indexer bindings do not live-refresh in this app's compiled-binding pipeline). + +--- + +## 18. Terminology + +| Term | Definition in V2 | +|---|---| +| **Recipe** | A persisted, named, reusable binding configuration: a flat list of Mappings. Holds no data or output. `Recipes` table row. | +| **Mapping** | One element of a Recipe: one template slide + its text rules + its image rules + one-or-more worksheet data sources. Unnamed value object. | +| **WorksheetSource** | One worksheet as a data feed, with an optional column subset and row filter. | +| **RowFilter** | How rows of a worksheet are selected: All, an inclusive 1-based index range, or one block of an N-way partition. | +| **Template** | Not an entity — a `(presentation file, 1-based slide index)` reference (`PresentationSource`). Cloned once per data row at generation. | +| **Placeholder** | A Mustache tag (`{{Name}}`) in a template shape's text; a text binding target. | +| **Image shape** | A template shape that already contains a picture; an image binding target. Identified by shape name. | +| **TextInstruction** | Rule: first non-empty value across a column list → every placeholder in a tag set. | +| **ImageInstruction** | Rule: first non-empty image source across a column list → every shape in a shape set, cropped via an ordered `RoiOption` chain, with a fallback image. | +| **RoiOption** | A crop strategy: anchor-based (`Image`/`Face`/`Eyes`/`Nose`/`Mouth`, geometry + face detection) or interest-based (`Entropy`/`Attention`/`Low`/`High`/`All`, content-aware). Tried in order. | +| **Recipe Editor** | The inline page state (Guided wizard or Advanced layout) for authoring a Recipe. Not a shell destination. Not a slide editor. | +| **Guided / Advanced** | Two layouts of the same Recipe Editor. Guided = a 4-step wizard (Template → Data → Binding → Review). Advanced = everything at once + mapping navigator + shape inspector. | +| **Binding state** | `Assigned` / `Suggested` (auto-matched, unconfirmed) / `NeedsSelection` (ambiguous) / `Unassigned`. | +| **Run** / **Request** | One user-initiated generation of a Recipe. `requestId` (GUID string). `Requests` table row (write-once). | +| **Generate** | Pressing "Start" in the Run dialog → `IService.CreateAsync` → spawns Jobs; returns immediately. | +| **Job** | One unit of generation = one Mapping × one WorksheetSource = one output file. `JobId` = 0-based ordinal int within its Run. `Jobs` table row (current state). | +| **JobSpecification** | The fully-resolved, self-contained description of a Job (no Recipe lookup needed to run or resume). | +| **Phase** | One of `CreatingOutput → CreatingSlides → FillingText → FillingImages → Done`. Forward-only. | +| **CurrentIndex** | Rows completed within the current phase; the resume position with `Phase`. | +| **TotalRows** | Filtered row count of the Job's worksheet; drives the determinate progress bar. | +| **Job Engine** | The generic, domain-free scheduler (`SlideGenerator.Jobs`). | +| **Job Workload** | The slide-specific 4-phase pipeline (`SlideGenerationWorkload`). | +| **Summary** | A per-Run snapshot returned by the list APIs (request-level fields + a dict of `JobSummary`). Rebuilt on every call. | +| **RowProgress** | A live-only, unpersisted per-row activity event (status, stage, note). | +| **Setting** | The single global config tree: Appearance (UI), Performance (generation), Network (generation). `UserSettings.json`. | +| **`.recipe`** | A portable zip package: the Recipe JSON + bundled workbook/presentation files. Import creates a new Recipe. | +| **Data.db** | The one shared SQLite database: `Recipes` + `Requests` + `Jobs`. | +| **`MaxConcurrentJobs`** | The only concurrency control — how many Jobs run their workload at once. Default 5. | + +### Misleading V1 terms — do NOT carry into V2 + +| V1 term | Why it must not appear in V2 UI/IA | +|---|---| +| **Studio** | No such concept. There is no 3-tab studio, no combined generation workspace. Rejected in `plans/status.md`. | +| **Run / Check** (the old paired UI areas) | Removed per `plans/idea.md` §8 — replaced by record/text/image *count* statistics on the recipe detail. | +| **IPC sidecar / JSON-RPC / StreamJsonRpc / `SlideGenerator.Stdio`** | The whole IPC layer is gone. Everything is one in-process app. | +| **Workflow / WorkflowCore / "workflow engine"** | Replaced by plain Task-based Jobs. The `Logs\Workflows\` folder name is a vestigial path, not a concept. | +| **Node / Edge / recipe graph** | The Recipe is a flat list. There is no graph editor (rejected). | +| **Coordinator / concurrency gates / performance calibration** | Removed. Only `MaxConcurrentJobs`. | +| **Acquisition / Collector** | Modules deleted. | +| **Cryptography module** | Folded into Utilities. | + +--- + +## 19. V1 vs V2 Differences + +The design agent may have V1 screenshots. These will mislead: + +``` +V1 → V2 +───────────────────────────────────────────────────────────────────────────── +Tauri web frontend + Rust host → single Avalonia .NET desktop app +Backend as a JSON-RPC IPC sidecar (Stdio) → backend runs in-process; no IPC +WorkflowCore-driven job execution → plain Task-based Job Engine + Workload +Recipe = Node/Edge graph → Recipe = flat List +"Studio" (multi-tab generation workspace) → no Studio; inline Recipe Editor + inside the Recipes page +3-tab Studio / wizard-separate-from-editor → Guided/Advanced are two layouts of ONE editor +"Run" + "Check" panels on a recipe → record / text / image count stats +Coordinator: 3 concurrency gates + a → one setting: MaxConcurrentJobs (default 5) + hardware/network calibration system +Per-purpose DBs (Workflows.db, Recipes.db, → one Data.db (Recipes | Requests | Jobs) + Cache.db, Studio.db) +SHA-256 in a Cryptography module → Utilities/Sha256.cs +Acquisition + Collector modules → deleted +``` + +**V1 assumption the IA must not reproduce:** "the user works in a Studio to build and run a generation." **V2 reality:** the user manages a flat list of Recipes on one page (with an inline editor), and a flat list of Runs on another. Generation is a modal dialog, not a workspace. + +**V1 assumption:** "a Recipe maps to one template / one output." **V2 reality:** a Recipe has N Mappings, each with its own template slide and 1..N worksheet sources; a Run of it produces N × (sources) output files. + +**V1 assumption:** "there is a Run view and a Check view." **V2 reality:** replaced by simple counts; see `plans/idea.md` §8. + +--- + +## 20. Domain Diagram + +``` + ┌──────────────────────────────┐ + │ Recipe (SQLite row) │ + │ Id · Name · timestamps │ + └───────────────┬──────────────┘ + │ owns 1..N (value objects) + ▼ + ┌──────────────────────────────┐ + │ Mapping (unnamed) │ + └───┬───────────┬───────────┬──┘ + references 1 │ owns │ 0..N │ owns 0..N + ▼ ▼ ▼ + ┌──────────────────────┐ ┌───────────┐ ┌──────────────────┐ + │ PresentationSource │ │ Text │ │ ImageInstruction│ + │ = template .pptx + │ │Instruction│ │ (+ RoiOption[] │ + │ 1-based slideIndex │ └───────────┘ │ + fallback img)│ + └──────────┬───────────┘ │ └──────────────────┘ + │ owns 1..N │ + │ ▼ keyed by + ┌────────────────┐ Mustache tags / shape names + │ WorksheetSource│ scanned from the template slide + │ workbook + │ + │ sheet + │ + │ UsedColumns? │ + │ RowFilter? │ + └────────────────┘ + + ───────────────────── R U N T I M E ───────────────────── + + User → Run dialog → Request(RecipeId, Name, OutputType, SaveFolder) + │ IService.CreateAsync + │ Service.BuildJobs = Mappings × Sources + ▼ + ┌───────────────────────────────────────────────┐ + │ Request (Run) requestId (GUID) │ + │ Requests table row (write-once) + .log file │ + └───────────────┬───────────────────────────────┘ + │ owns 1..N + ▼ + ┌───────────────────────────────────────────────┐ + │ Job (RequestId, JobId ordinal) │ + │ JobSnapshot row: JobStatus · JobPhase · │ + │ CurrentIndex · TotalRows · JobSpecification │ + │ (fully resolved — no Recipe lookup) │ + └───────────────┬───────────────────────────────┘ + │ runs, forward-only + ▼ + CreatingOutput → CreatingSlides → FillingText → FillingImages → Done + (copy template, (1 cloned slide (compose (download/local, + strip slides) per data row) Mustache) crop, set image) + │ writes │ live events + ▼ ▼ + one output .pptx per Job RequestProgress / JobSnapshot / + under {SaveFolder}\{book}\ RowProgress → ProgressHub → + {sheet}{ext} Runs page (250 ms coalesced) + + Job status: Pending → Running ⇄ Paused → { Complete | Cancelled | Error } + Control: Pause/Resume/Stop — at the REQUEST level only, never per-Job + Crash: non-terminal Jobs auto-resume from (Phase, CurrentIndex) at startup +``` + +--- + +## 21. Design Constraints + +Things the UI/UX design **must** respect, each backed by the code: + +**Recipe & Template** +- A Recipe is **not** tied to a single template. It holds N Mappings, each with its own `(presentation file, slide index)`. Design the editor and the recipe detail for the multi-Mapping case. +- Templates are file references, not managed entities. There is no template library to browse. Selecting a template = pick a file, then pick a slide. +- The same template file / slide is freely reusable across Recipes and Mappings. +- Only two kinds of thing in a template slide are bindable: Mustache `{{tags}}` in text, and shapes that already contain a picture. An empty box is invisible to the editor — the design should tell the user their template shape needs a placeholder image. +- Within one Run, a given worksheet can feed at most one Mapping (output path collision). The editor / run preview must surface this. + +**Recipe Editor** +- One editor, two layouts (`IsGuided`). Guided is a linear 4-step wizard reusing the same panels. New recipe → Guided; existing → Advanced. "Open Advanced" is one-way and **not remembered per recipe** (no storage for that preference — **Unknown / Not determined from the code** whether it ever will be). +- Binding has four states (`Assigned` / `Suggested` / `NeedsSelection` / `Unassigned`) shared between text and image panels; the summary count ("N ghép · N đề xuất · N cần chọn · N chưa gán") is a first-class UI element. +- The editor persists a strictly 1:1 model (one placeholder ↔ one column). N-column fallback chains from imports are silently flattened on re-save — the design should at least not pretend the editor round-trips them. +- Save is nearly unvalidated (dirty + name). Only "Save and run" blocks on missing template / unresolved bindings. +- The editor lives **inside the Recipes page**, not as a separate nav destination. + +**Runs & Execution** +- Generation is **asynchronous**. "Start" returns instantly; work happens in the background and is observed via a live progress stream + periodic list reloads. +- A Run expands into N Jobs, one output file each. The design must show a Run→Jobs hierarchy. +- Control (Pause / Resume / Stop) is **Request-level only**. There is no per-Job pause/stop. Job rows in the UI are read-only. +- Pause/stop granularity is "between rows" — a paused Job finishes its current row first. +- Jobs **auto-resume after an app crash** from `(Phase, CurrentIndex)`. The Runs page must handle Jobs that were mid-flight last session and are now running again. +- Determinate progress needs `TotalRows` (nullable) — fall back to indeterminate when null. +- **There is no Request-level error state.** An all-failed Run shows as `Complete` in the "Done" filter; the only error signal is per-Job `Error` inside the detail. Design the Runs page to make Job-level failure discoverable despite the missing aggregate. +- `RunStatusFilter` = All / Running / Paused / Done / Cancelled — no "Errored" chip exists. +- Do **not** design chips/states for `JobPhase.Queued`, `RowStatus.Waiting`, `RowStatus.Error`, or `RowStage.CroppingImage` — declared but never emitted. +- Row-level detail (activity line: which URL is downloading, etc.) is **live-only, never persisted**. A completed Run has no per-row history — only Job phase/index and the `.log` file. +- Output files and `.log` files are never auto-deleted, even when a Run is deleted. Per-job download caches are deleted on Job completion. +- The Run dialog must live-preview the exact Job fan-out and every output-path conflict (`PreviewAsync`/`PlannedJob`/`ConflictKind`) and disable "Start" on any conflict. +- The `AllowLocalPaths` checkbox is currently a **no-op** — local paths always work regardless. + +**Settings** +- Three groups: Appearance (**UI only**), Performance (**affects generation** — `MaxConcurrentJobs`), Network (**affects generation** — proxy, retry, download cap). +- Every change persists **immediately** — no Save button anywhere. Design accordingly (no dirty/save affordance). +- Reset is **per-group** ("restore defaults"), not whole-app. +- No validation on numeric fields — the design's input controls are the only guardrail. +- Theme (System/Light/Dark), language, and reduced-motion all apply **live, no restart**. +- Nothing about window state, last page, or recent folders is persisted. + +**System model** +- Local-first, **not offline**. Recipes/Runs/Settings are fully local (SQLite/JSON). The network is required for: row image downloads (HTTP + Google Drive only), the About page's contributor/sponsor lists, and app updates (Velopack/GitHub Releases). Degrade gracefully when offline; do not present the app as network-free. +- Single-instance app (second launch exits). +- Windows-first (`explorer.exe` for "open folder"; `%LOCALAPPDATA%` / `%TEMP%` layout; a portable build variant exists). +- Syncfusion licence is required at runtime for any generation or template/workbook preview. +- About page must never show an error state for a failed network fetch — empty lists instead. +- Developer role badges (crown / computer / paint from `plans/idea.md`): the login→role map was **never supplied** — **Unknown / Not determined from the code**. The `Contributor` model has no role field. + +**Vocabulary** +- Use: Recipe, Mapping, Run, Job, Template, Placeholder, Binding, Phase, Settings, Import/Export. +- Never use: Studio, Workflow, Node/Edge, Coordinator, IPC/sidecar, the paired "Run/Check" panels. diff --git a/plans/idea/1.png b/plans/idea/1.png new file mode 100644 index 00000000..f14c4aac Binary files /dev/null and b/plans/idea/1.png differ diff --git a/plans/idea/10.png b/plans/idea/10.png new file mode 100644 index 00000000..f4822a6e Binary files /dev/null and b/plans/idea/10.png differ diff --git a/plans/idea/11.png b/plans/idea/11.png new file mode 100644 index 00000000..0b5a80e9 Binary files /dev/null and b/plans/idea/11.png differ diff --git a/plans/idea/12.png b/plans/idea/12.png new file mode 100644 index 00000000..ec3f8db8 Binary files /dev/null and b/plans/idea/12.png differ diff --git a/plans/idea/13.png b/plans/idea/13.png new file mode 100644 index 00000000..4fabb2a6 Binary files /dev/null and b/plans/idea/13.png differ diff --git a/plans/idea/14.png b/plans/idea/14.png new file mode 100644 index 00000000..7d5e2483 Binary files /dev/null and b/plans/idea/14.png differ diff --git a/plans/idea/15.png b/plans/idea/15.png new file mode 100644 index 00000000..49559efc Binary files /dev/null and b/plans/idea/15.png differ diff --git a/plans/idea/16.png b/plans/idea/16.png new file mode 100644 index 00000000..43af88ec Binary files /dev/null and b/plans/idea/16.png differ diff --git a/plans/idea/17.png b/plans/idea/17.png new file mode 100644 index 00000000..06ef32de Binary files /dev/null and b/plans/idea/17.png differ diff --git a/plans/idea/18.png b/plans/idea/18.png new file mode 100644 index 00000000..f4f9ea45 Binary files /dev/null and b/plans/idea/18.png differ diff --git a/plans/idea/19.png b/plans/idea/19.png new file mode 100644 index 00000000..bcb25c46 Binary files /dev/null and b/plans/idea/19.png differ diff --git a/plans/idea/2.png b/plans/idea/2.png new file mode 100644 index 00000000..57ce80f8 Binary files /dev/null and b/plans/idea/2.png differ diff --git a/plans/idea/20.png b/plans/idea/20.png new file mode 100644 index 00000000..6e4d136c Binary files /dev/null and b/plans/idea/20.png differ diff --git a/plans/idea/21.png b/plans/idea/21.png new file mode 100644 index 00000000..a46aa0b7 Binary files /dev/null and b/plans/idea/21.png differ diff --git a/plans/idea/22.png b/plans/idea/22.png new file mode 100644 index 00000000..61609f1c Binary files /dev/null and b/plans/idea/22.png differ diff --git a/plans/idea/23.png b/plans/idea/23.png new file mode 100644 index 00000000..56a6247e Binary files /dev/null and b/plans/idea/23.png differ diff --git a/plans/idea/24.png b/plans/idea/24.png new file mode 100644 index 00000000..40bb07a3 Binary files /dev/null and b/plans/idea/24.png differ diff --git a/plans/idea/25.png b/plans/idea/25.png new file mode 100644 index 00000000..26e25ed2 Binary files /dev/null and b/plans/idea/25.png differ diff --git a/plans/idea/26.png b/plans/idea/26.png new file mode 100644 index 00000000..371c3af9 Binary files /dev/null and b/plans/idea/26.png differ diff --git a/plans/idea/27.png b/plans/idea/27.png new file mode 100644 index 00000000..622d6422 Binary files /dev/null and b/plans/idea/27.png differ diff --git a/plans/idea/28.png b/plans/idea/28.png new file mode 100644 index 00000000..47fe5578 Binary files /dev/null and b/plans/idea/28.png differ diff --git a/plans/idea/3.png b/plans/idea/3.png new file mode 100644 index 00000000..e4ac16a4 Binary files /dev/null and b/plans/idea/3.png differ diff --git a/plans/idea/4.png b/plans/idea/4.png new file mode 100644 index 00000000..c894fbfd Binary files /dev/null and b/plans/idea/4.png differ diff --git a/plans/idea/5.png b/plans/idea/5.png new file mode 100644 index 00000000..6dd6c66c Binary files /dev/null and b/plans/idea/5.png differ diff --git a/plans/idea/6.png b/plans/idea/6.png new file mode 100644 index 00000000..bf68ecb3 Binary files /dev/null and b/plans/idea/6.png differ diff --git a/plans/idea/7.png b/plans/idea/7.png new file mode 100644 index 00000000..556ce73c Binary files /dev/null and b/plans/idea/7.png differ diff --git a/plans/idea/8.png b/plans/idea/8.png new file mode 100644 index 00000000..fdc98e44 Binary files /dev/null and b/plans/idea/8.png differ diff --git a/plans/idea/9.png b/plans/idea/9.png new file mode 100644 index 00000000..4af16259 Binary files /dev/null and b/plans/idea/9.png differ diff --git a/plans/idea/idea.md b/plans/idea/idea.md new file mode 100644 index 00000000..8de87b70 --- /dev/null +++ b/plans/idea/idea.md @@ -0,0 +1,428 @@ +# SlideGenerator — Frontend Design & Implementation Brief + +## 1. Tổng quan + +Đây là toàn bộ ý tưởng frontend cho project **SlideGenerator**. + +Frontend sẽ được xây dựng bằng **Avalonia UI** và mục tiêu cuối cùng là triển khai **frontend E2E**, không chỉ dừng lại ở việc thiết kế giao diện trong Figma. + +Thiết kế cần được thực hiện với khả năng triển khai thực tế trong Avalonia làm mục tiêu. Vì vậy, ngoài việc thiết kế các page, cần xây dựng hệ thống **components, states, interactions, animations và application shell** đủ rõ ràng để có thể chuyển thành frontend hoàn chỉnh. + +Project tương đối phức tạp nên không cần cố gắng giải quyết tất cả mọi thứ ngay từ đầu. Có thể tập trung vào việc: + +* Thiết kế các UI components cần thiết. +* Đặt các components vào trong các pages để minh họa cách sử dụng. +* Thiết kế application shell và toolbar. +* Thiết kế các trạng thái, interaction và animation quan trọng. +* Sau đó triển khai frontend thực tế bằng Avalonia. + +Nếu một component có vấn đề hoặc cách thể hiện chưa được chỉ rõ, có thể chủ động thiết kế theo cách phù hợp. + +--- + +# 2. Công nghệ và phạm vi triển khai + +Frontend sử dụng: + +**Avalonia UI** + +Mục tiêu là triển khai **frontend E2E**. + +Điều này có nghĩa là Figma/design chỉ là bước thiết kế; kết quả cuối cùng cần là một frontend Avalonia thực tế của SlideGenerator. + +UI nên được tổ chức theo hướng component có thể tái sử dụng trong code, thay vì thiết kế từng màn hình như những sản phẩm hoàn toàn độc lập. + +Các phần chính cần được xem xét trong toàn bộ frontend: + +* Application shell. +* Toolbar phía trên cùng. +* Navigation/sidebar nếu cần. +* Các page chính. +* Reusable components. +* Light/Dark theme. +* Theme transition animation. +* Logo animation. +* Hover/focus/active states. +* Các UI state cần thiết cho application. + +--- + +# 3. Phạm vi thiết kế + +Vì project khá phức tạp, không cần thiết kế mọi thứ thành một màn hình hoàn chỉnh ngay lập tức. + +Có thể tập trung vào: + +1. Thiết kế các **components**. +2. Tạo các **variants/states** cần thiết cho components. +3. Đặt chúng vào các **pages** để minh họa. +4. Đảm bảo các pages và components tạo thành một application UI thống nhất. +5. Sau đó dùng thiết kế này làm cơ sở để triển khai frontend bằng Avalonia. + +Các component nhỏ hoặc phổ biến có thể sử dụng thư viện UI bên ngoài thay vì tự thiết kế lại từ đầu. + +--- + +# 4. Phong cách giao diện + +Phong cách tổng thể mong muốn: + +* Hiện đại. +* Thoáng. +* Không quá dày đặc. +* Có cảm giác polished. +* Gần với style của **v1** đang thực hiện. +* Có thể tham khảo **Unsloth** về cách tổ chức giao diện, khoảng trống và animation. + +Không cần thiết kế theo kiểu quá nặng tính enterprise hoặc quá nhiều panel nhỏ chen chúc nhau. + +--- + +# 5. Application Shell + +Application shell là một phần quan trọng của frontend. + +Đặc biệt, **toolbar phía trên cùng cũng là một phần chính thức của giao diện app và phải được thiết kế**. + +Không nên chỉ tập trung vào phần nội dung page rồi bỏ qua toolbar. + +Toolbar cần được xem như một reusable part của application shell và phải phù hợp với toàn bộ hệ thống UI. + +--- + +# 6. Logo và logo animation + +Project có **logo animation**. + +Quy tắc mong muốn: + +> Khi một khu vực/component hiển thị logo đầy đủ cả tên thì sẽ hiển thị cả animation. + +Animation có trình tự: + +**logo → animation → full → giữ** + +Sau khi animation hoàn thành, logo sẽ ở trạng thái đầy đủ và giữ nguyên trạng thái đó. + +Logo animation không chỉ được xem là một animation riêng lẻ, mà là một phần của visual identity của application. + +--- + +# 7. About Page + +About Page cần có các phần theo thứ tự sau. + +## Logo + +Hiển thị **logo đầy đủ cả tên** ở giữa. + +Tại những nơi hiển thị logo đầy đủ tên, sử dụng logo animation đã mô tả ở trên. + +## Mô tả phần mềm + +Hiển thị ở giữa: + +**An automated, template-based presentation generator** + +và: + +**Phần mềm tự động tạo slide thuyết trình theo mẫu.** + +Nội dung được căn giữa. + +## Kiểm tra cập nhật + +Có một khu vực dành cho **Check for Updates**. + +Khu vực này cần hiển thị: + +* Phiên bản hiện tại. +* Thông tin cập nhật. + +Cách trình bày có thể tham khảo cách **Google Chrome** hiển thị update. + +## Developers + +Hiển thị những người tham gia phát triển project. + +Phần này có thể được cá nhân hóa cho từng người. + +Ví dụ: + +* Chủ project có thể có crown. +* Developer chức năng có thể có icon computer. +* Designer có thể có icon paint. +* Các icon nghiêng khoảng 30°. +* Tên có thể được thu gọn. +* Khi hover thì hiển thị tên. + +Đây chỉ là ví dụ về ý tưởng. + +Có thể customize cách thể hiện tùy ý để tạo cảm giác mỗi thành viên có một identity riêng. + +Thông tin như **ảnh và tên của từng người sẽ được tự động update**. + +## GitHub + +Có một khu vực dành cho **GitHub repository**. + +## Supporters + +Có một khu vực dành cho supporter. + +Cần có: + +* Một nơi để người dùng click vào để ủng hộ. +* Danh sách/hiển thị những người đã ủng hộ. + +Có thể tham khảo concept của **osu!supporter**: + +https://osu.ppy.sh/home/support + +## Copyright + +Cuối trang hiển thị: + +`© 2024 - {current year} Thanh Mai. Released under AGPL-3.0.` + +--- + +# 8. Thay đổi ở khu vực Run / Check + +Ở phần giao diện trước đây có **Run** và **Check** thì bỏ hai phần này. + +Thay vào đó hiển thị thống kê: + +* Số records. +* Số text. +* Số image. + +--- + +# 9. Đa ngôn ngữ / i18n + +Application là **đa ngôn ngữ**. + +Vì vậy trong UI design **không hard-code text trực tiếp**. + +Thay vào đó sử dụng **i18n key**. + +Ví dụ: + +`recipes.recipe.name` + +Tức là trong thiết kế có thể hiển thị key thay cho text thực tế. + +Convention có thể tham khảo: + +https://www.locize.com/blog/guide-to-i18n-key-naming + +Mục tiêu là frontend có thể thay text theo localization system mà không cần thay đổi cấu trúc UI. + +Ví dụ thay vì thiết kế: + +`Recipe Name` + +thì sử dụng: + +`recipes.recipe.name` + +Khi implementation có translation tương ứng thì key này sẽ được thay bằng text thực tế. + +--- + +# 10. Light / Dark Theme + +Application có **Light Mode** và **Dark Mode**. + +Khi chuyển giữa hai theme, không đổi theme một cách tức thời. + +Cần có **animation chuyển đổi theme**. + +Ý tưởng animation mong muốn là kiểu: + +**phóng to ra bên ngoài** + +Có thể tham khảo transition của **Unsloth**. + +Mục tiêu là tạo cảm giác theme đang thực sự chuyển đổi thay vì chỉ đổi toàn bộ màu sắc ngay lập tức. + +--- + +# 11. UI Components + +Không cần tự viết tất cả component nhỏ từ đầu. + +Có thể sử dụng các UI component library bên ngoài để tránh phải xây dựng lại những component cơ bản. + +Ví dụ có thể tham khảo: + +### shadcn/ui + +https://ui.shadcn.com/docs/figma + +### Magic UI + +https://magicui.design/ + +Có thể tham khảo cách **Unsloth sử dụng shadcn/ui kết hợp với Magic UI**. + +### Icons + +Có thể sử dụng: + +**Hugeicons** + +https://hugeicons.com/ + +Khi cần tham khảo một thư viện/component library khác trong Figma, có thể tìm theo: + +`{Tên thư viện} + "figma"` + +--- + +# 12. Component Design + +Do mục tiêu cuối cùng là frontend Avalonia E2E, các components cần được thiết kế theo hướng có thể tái sử dụng. + +Không chỉ thiết kế trạng thái mặc định. + +Khi cần thiết, component nên có các state tương ứng như: + +* Default. +* Hover. +* Active. +* Selected. +* Disabled. +* Focus. +* Loading. +* Error. + +Các state này chỉ cần được thiết kế ở những component thực sự cần. + +Không cần cố gắng tạo ra thật nhiều variant nếu UI không sử dụng chúng. + +--- + +# 13. Pages + +Các components sau khi thiết kế nên được đặt vào các pages để minh họa giao diện thực tế của application. + +Mục đích không phải chỉ để có các component riêng lẻ đẹp mắt, mà cần thể hiện: + +* Component hoạt động cùng các component khác như thế nào. +* Khoảng cách và hierarchy giữa các thành phần. +* Layout của page. +* Application shell. +* Toolbar. +* Navigation. +* Theme. +* Animation/interaction cần thiết. + +--- + +# 14. Animation và Interaction + +Animation là một phần quan trọng của visual design. + +Đặc biệt cần chú ý: + +### Logo + +`logo → animation → full → giữ` + +### Theme transition + +Khi đổi Light/Dark mode có animation mở rộng ra bên ngoài. + +Ngoài hai animation trên, các animation/transition khác có thể được thiết kế khi cần để UI có cảm giác tự nhiên hơn. + +Không cần biến mọi interaction thành animation nếu điều đó không cần thiết. + +--- + +# 15. Design direction + +Tổng thể frontend nên giữ một visual language thống nhất. + +Các điểm quan trọng nhất: + +**Modern + Spacious + Polished** + +Có thể lấy cảm hứng từ: + +* SlideGenerator v1. +* Unsloth. +* shadcn/ui. +* Magic UI. +* osu! Supporter cho khu vực supporter. + +Các reference trên dùng để tham khảo cách tổ chức và cảm giác giao diện, không nhất thiết phải sao chép nguyên bản. + +--- + +# 16. Figma và implementation + +Figma được sử dụng để xác định design system, components và page layout. + +Tuy nhiên mục tiêu cuối cùng không phải chỉ tạo một file Figma đẹp. + +Design cần hướng tới: + +**Figma → Avalonia implementation → frontend E2E** + +Do đó, trong quá trình thiết kế cần lưu ý rằng: + +* Component phải có khả năng triển khai thực tế. +* Layout phải phù hợp với application desktop. +* States cần đủ rõ để implementation. +* Theme cần có cấu trúc rõ ràng. +* Animation cần có cách thể hiện đủ rõ để triển khai. +* Components nên reusable. + +--- + +# 17. Những gì không cần quá cứng nhắc + +Đây là ý tưởng tổng thể chứ không phải mọi chi tiết đều đã được quyết định. + +Nếu một component có vấn đề, layout không hợp lý hoặc một chi tiết chưa được mô tả cụ thể thì có thể chủ động đưa ra cách thiết kế phù hợp. + +Không cần cố gắng bám từng ví dụ một cách máy móc. + +Ví dụ crown/computer/paint ở developer section chỉ là cách minh họa cho ý tưởng **cá nhân hóa từng developer**. + +--- + +# 18. Mục tiêu cuối cùng + +Mục tiêu là có một frontend SlideGenerator hoàn chỉnh, hiện đại và nhất quán, được triển khai **E2E bằng Avalonia**. + +Figma/design cần giúp xác định rõ: + +**Design system → Components → Pages → Interactions → Animations → Avalonia frontend** + +Project khá phức tạp nên không cần giải quyết tất cả trong một bước. + +Điều quan trọng là xây dựng được nền tảng UI đủ tốt để sau đó có thể tiếp tục triển khai logic frontend và backend. + +Nếu có điểm nào chưa rõ hoặc cần quyết định thêm, có thể hỏi trực tiếp để thống nhất trước khi triển khai. + +--- + +## Reference + +* Unsloth — visual style, spacing, theme transition. +* osu! Supporter — supporter section. +* shadcn/ui — component reference. +* Magic UI — component/animation reference. +* Hugeicons — icon set. +* Locize — i18n key naming convention. + +## Tinh thần chung + +Project này khá phức tạp, và phần frontend cũng sẽ là một phần lớn của toàn bộ application. + +Có thể chủ động sáng tạo trong những phần chưa được quy định cụ thể, nhưng cần giữ đúng những ý tưởng cốt lõi ở trên. + +**Cố lên nhé.** diff --git a/plans/status.md b/plans/status.md new file mode 100644 index 00000000..e42d2de5 --- /dev/null +++ b/plans/status.md @@ -0,0 +1,37 @@ +# Tiến độ overhaul Avalonia frontend + +Cập nhật: 2026-09-01. Kế hoạch đầy đủ: `C:\Users\haith\.claude\plans\snoopy-tinkering-river.md` (ngoài repo, không commit được). + +## Trạng thái theo phase + +| Phase | Trạng thái | Ghi chú | +|---|---|---| +| P0 | Xong | 2 spike (custom titlebar, theme reveal) — kết luận đã áp dụng ở P2 | +| P1 | Xong | Foundation: token, icon, style class, `DesignSystemTests` gate + 2 bug crash/theme-runtime | +| P2 | Xong | Shell mới (toolbar-titlebar) + motion + theme reveal hình tròn | +| P3 | Xong | i18n dot-key migration toàn bộ | +| P4 (a+b+c) | Xong | Recipes/RecipeEditor/RunDialog/TemplatePicker polish | +| P5 | Xong | `TotalRows` backend contract + Runs live UI | +| P6 | Xong | About page + Settings rebuild + sponsors CI | +| P7 | Phần lõi xong, 3 mục treo chờ quyết định | xem dưới | + +## P7 — đã làm (commit `39e12589`, `b4604c3a`, `3f073309`) + +1. **Headless test activation**: `Avalonia.Headless.XUnit` 12.1.1 lệch version với `xunit.v3` 4.0.0 (`MissingMethodException` lúc discovery) → đổi sang gọi thẳng `HeadlessUnitTestSession` (bỏ package `.XUnit`). 4 test view-construct (Recipes/Runs/Settings/About) chạy được. Sau đó phát hiện + sửa 1 race condition: mỗi test class tự `StartNew` session riêng đụng độ `Application.Current` (process-wide singleton) khi xUnit chạy song song → gộp về `HeadlessTestSession.Instance` dùng chung cho cả assembly. +2. **Dọn `PlaceholderPageView`**: mồ côi (4/4 destination đã có trang thật) — xoá hẳn, `default:` case đổi thành `throw UnreachableException` thay vì ẩn lỗi tương lai. +3. **Sửa CLAUDE.md drift đã xác minh cụ thể**: tên/số lượng migration script (`0001/0002/0003` cũ → thực tế chỉ `001_2.0.0.sql` + `002_add-total-rows-to-jobs.sql`), đường dẫn `NameAndPaths.cs` (`Rules/` cũ → thực tế `Immutable/`), version test package (`xunit.v3`/`xunit.runner.visualstudio`/`NSubstitute` doc ghi cũ hơn thực tế), `SlideGenerator.Stdio/Program.cs` → `SlideGenerator.Desktop/Program.cs` (Stdio đã xoá khỏi solution). +4. **11 test VM/service mới**: stats aggregation (`RecipesViewModel`), `TotalRows` mapping (`JobRunViewModel`), `AboutViewModel` (mock HTTP), `ThemeService` reduced-motion branch. +5. **Bug thật bắt được khi mở rộng contrast gate**: nav-pill active tab + avatar initials (About page) hiện chữ dưới ngưỡng đọc được ở dark mode (2.57:1, cần ≥4.5:1) — do `AccentBrush` trên `AccentMutedBrush` chưa từng được `DesignSystemTests` gate check. Sửa: đậm `AccentMutedBrush` dark từ `#2A4E85` → `#152742` (giữ hue, chỉ đậm hơn) → 4.63:1. + +**Kết quả**: build solution 0 lỗi; **548 test, 547 xanh, 1 skip** (Syncfusion license, có từ trước, không phải regression). + +## 3 mục còn treo — cần chủ project quyết + +1. **CLAUDE.md's section "IPC Layer (SlideGenerator.Stdio)"** mô tả kiến trúc JSON-RPC/StreamJsonRpc **không còn tồn tại trong code** (module Stdio đã xoá hẳn khỏi solution, `grep StreamJsonRpc` rỗng). Đây là drift quy mô LỚN (cả 1 section + nhiều bảng liên quan trong CLAUDE.md), vượt xa phạm vi "sửa 4 câu drift nhỏ" đã làm — chưa tự ý viết lại, cần xác nhận có muốn dành riêng 1 việc cho việc này không. +2. **Focus ring bàn phím**: hiện chỉ có ở `Button` (từ P1) — `ToggleButton`/`ListBoxItem`/`TextBox`/`NumericUpDown` chưa có ring tuỳ biến, chỉ dựa Semi mặc định. Đã xác nhận rõ (không mơ hồ), cố ý chưa mở rộng để tránh vỡ ảnh đã duyệt ở các phase trước mà không có bằng chứng cụ thể đang hỏng. +3. **Ma trận 20 screenshot §7.0.B** (blueprint) chưa chạy chính thức theo checklist — bị chặn bởi thiếu `SYNCFUSION_LICENSE_KEY` lúc runtime cho Desktop app (không tìm thấy code nào tự load `.env`), nên không chạy được 1 lượt generation thật để chụp job-đang-chạy có tiến độ live. Đã hỏi từ P5 (câu hỏi mở Q8 trong plan gốc), chưa có câu trả lời. + +## Ràng buộc đang giữ + +- `plans/` KHÔNG BAO GIỜ commit vào git — file này (`status.md`) và toàn bộ `plans/` vẫn nằm ngoài git (`git status` xác nhận `?? plans/` trước mỗi commit). +- Không sửa file trong danh sách cấm chạm (ViewModels, `TrExtension.cs`, `ProgressHub.cs`, `MappingEditSession`, 10 module domain) mà không hỏi trước — chưa vi phạm lần nào trong P7. diff --git a/plans/ui.md b/plans/ui.md new file mode 100644 index 00000000..cb56339a --- /dev/null +++ b/plans/ui.md @@ -0,0 +1,116 @@ +# SlideGenerator V2 Desktop Frontend — Status Summary + +Bàn giao cho agent khác. Đây là bản tóm tắt của plan gốc (dài ~1400 dòng) tại +`C:\Users\haith\.claude\plans\h-y-l-p-plan-cho-pure-puzzle.md` trên máy tác giả — nếu cần đào sâu một quyết định +cụ thể (lý do, phương án đã bỏ, benchmark ảnh render_q1...), đọc file đó. File này đủ để tiếp tục làm việc mà +không cần đọc lại toàn bộ plan. + +## Bối cảnh + +Backend V2 (`SlideGenerator.Generator`/`.Recipe`/`.Settings`/... — 10 module domain, xem `CLAUDE.md` ở root repo) +đã hoàn chỉnh, chạy in-process (không IPC sidecar nữa). `SlideGenerator.Desktop` là frontend Avalonia — lúc bắt +đầu plan này chỉ là khung rỗng (`MainWindow` là `TextBlock` placeholder). Plan này xây toàn bộ frontend đó. + +Mô hình domain: **Recipe** = danh sách phẳng các **Mapping** (không phải graph/Node/Edge — model đó đã bị xoá +khỏi backend có chủ đích). Một Mapping = 1 template slide + N `WorksheetSource` (dữ liệu) + text/image +instructions. `Service.BuildJobs` fan-out `Mappings × Sources` thành job list. + +## Trạng thái: TẤT CẢ ĐÃ XONG (P-1 → P5, task 1-38) + +Không còn task nào treo trong plan. Build xanh, 523/524 test toàn solution qua (1 skip do thiếu secret +`SYNCFUSION_LICENSE_KEY`, pre-existing, không liên quan frontend). + +### Theo phase + +- **P-1** — Cổng validate UX: xác nhận `ProgressBar` ngang thắng `PhaseRing` (ring thua ở cả 16px/40px, xem + benchmark trong plan gốc §4.1), Guided bước ③ phải chia 2 nhóm con Văn bản/Ảnh, Advanced giữ nguyên §5.2.b. +- **P0** — 5 thay đổi backend nhỏ (đã duyệt trước khi làm UI, xem "Backend contracts" bên dưới). +- **P1** — Shell: design tokens (`Resources/Primitives.axaml`/`Semantic.axaml`/`Tokens.axaml`/`Controls.axaml`), + font (Inter + JetBrains Mono NL), `LocalizationService`+`TrExtension`, `ThemeService`, `MainWindow`+ + `ShellViewModel` (3 đích: Recipes/Runs/Settings), `SplashView` (animation lockup, không chặn UI thread nữa), + `ViewLocator`, `IDialogService`/`IFilePicker`. +- **P2** — Runs (đọc trước, ít rủi ro nhất): `ProgressHub` (coalesce event bus + log, `DispatcherTimer` 250ms, + subscribe **trước** `service.InitializeAsync()` — thứ tự bắt buộc, xem Threading bên dưới), `RunsViewModel`/ + `RunsView` master-detail. +- **P3** — Recipes list + Run dialog: CRUD qua `IRecipeRepository`, export/import `.recipe`, `RunDialogView` + + `IService.PreviewAsync` (preview job list + conflict). +- **P4** — Recipe editor (phần khó nhất, chia 6 lượt con P4.1-P4.6): coordinator (`RecipeEditorViewModel` + + `MappingEditSession`), canvas overlay + auto-bind 4 mức (Exact/Normalized/Ambiguous/None), text bindings, + worksheet sources, mapping navigator (thêm/xoá/đổi thứ tự), inspector (ROI reorder + fallback image), + double-click canvas để gán cột nhanh, Save/dirty-tracking/validation (chặn "Lưu và chạy" khi còn Ambiguous, + KHÔNG chặn "Lưu" thường). +- **P4.5** — Guided mode: `IsGuided` flag (không phải ViewModel thứ hai) + `GuidedStep` enum (Template→Data→ + Binding→Review), recipe mới mặc định Guided, recipe có sẵn mặc định Advanced. +- **P5** — Settings page (Giao diện/Hiệu năng/Mạng + Giới thiệu), bàn phím (Ctrl+S/Ctrl+F/Delete/Esc), audit + accessibility + pre-flight §10 (xem "Gap còn lại" bên dưới). + +## Kiến trúc & quy ước quan trọng + +- **MVVM feature-folder** dưới `src/SlideGenerator.Desktop/Features/{Recipes,Runs,RecipeEditor,Settings}/` + (`Views/`, `ViewModels/`, `Models/`) — khác quy ước 10 module domain (xem `CLAUDE.md`), vì đây là UI code. +- **`ShellViewModel`** giữ 3 page ViewModel làm property, `CurrentPage` trỏ 1 trong 3 — không + `INavigationService` (3 đích cố định). +- **`ProgressHub`** (`Services/Progress/`) là điểm gom duy nhất giữa `GeneratingEventBus`/`LogNotifier` (bắn từ + thread nền, không throttle) và UI. ViewModel không bao giờ chạm event bus thô. +- **`LocalizationService`+`TrExtension`** (`Services/Localization/`): đổi ngôn ngữ live không cần khởi động lại. + **Lưu ý kỹ thuật quan trọng** — binding indexer (`Binding("[key]")`) KHÔNG live-refresh được trong pipeline + compiled-binding của app này dù `PropertyChanged` bắn đúng convention; phải bind qua một named property thường + (`Revision`, int) + `IValueConverter` (`Converters/LocalizedTextConverter.cs`) tra lại theo `ConverterParameter`. + Xem doc comment đầy đủ trong `TrExtension.cs` nếu định sửa cơ chế này. +- **`ThemeService`** (`Services/Theme/`) áp `Setting.Appearance.Theme` → `Application.RequestedThemeVariant`, và + từ P5 audit cũng áp `ReducedMotion` → zero/restore 2 resource `MotionUi`/`MotionBrand` (`Application.Current + .Resources["MotionUi"]`, DynamicResource nên mọi nơi dùng token này tự cập nhật). +- **Recipe editor**: `RecipeEditorViewModel` là coordinator thật, dựng 3 VM con (`SlideCanvasViewModel`/ + `TextBindingsViewModel`/`WorksheetSourcesViewModel`). `MappingEditSession` bọc 1 `Mapping` + touched-set + (HashSet shape/placeholder đã được user xác nhận) — danh tính ổn định qua lại giữa các mapping, khác `Mapping` + record (so sánh theo giá trị). Dirty-tracking dùng event `Changed` tường minh bắn tại đúng điểm sửa (không + dùng record-equality — `IReadOnlyList`/`IReadOnlySet` so theo tham chiếu, sai). +- **`IJobEngine`/`IJobRunner`**: điều khiển chỉ ở cấp request (`Stop/Pause/Resume(requestId)`), không có API + cấp job — hàng job trong UI luôn read-only. + +## Backend contracts frontend phụ thuộc (đã làm ở P0, đọc để hiểu vì sao UI như vậy) + +| Method/field | Ở đâu | Vì sao UI cần | +|---|---|---| +| `IReadOnlySlide.SlideSize` | `SlideGenerator.Document/Presentations/Components/Slide.cs` | Tính scale overlay canvas từ px @96 DPI | +| `Service.FindDuplicateOutputPath` (static, thuần) | `SlideGenerator.Generator/Service.cs` | Run dialog + `CreateAsync` dùng chung logic phát hiện trùng path nội bộ 1 request | +| `Setting.AppearanceSetting` (Theme/Language/ReducedMotion) | `SlideGenerator.Settings/Mutable/` | Chỗ lưu duy nhất cho theme/ngôn ngữ/reduced-motion | +| `includeLogs` param trên `ListActiveAsync`/`ListCompletedAsync` | `SlideGenerator.Generator/Service.cs` | Runs list không đọc/parse toàn bộ `.log` mọi request chỉ để vẽ list | +| `IService.PreviewAsync` + `PlannedJob`/`ConflictKind` | `SlideGenerator.Generator/Service.cs` | Run dialog hiện trước N file sẽ tạo + xung đột, không tự chế logic riêng | + +## Gap còn lại (không phải bug ẩn — đều đã ghi rõ, không chặn dùng) + +1. **`ShellView`'s `CrossFade` page-transition** không tôn trọng Reduced motion — `CrossFade.Duration` là CLR + property thường, không bind được qua `DynamicResource`. Sửa đúng cần viết page-transition tuỳ biến. +2. **`ItemsControl` (log lines trong `RunsView`, `PlannedJobs` trong `RunDialogView`)** không ảo hoá — `ListBox` + (Recipes/Runs master list) thì có (mặc định Avalonia). Job chạy lâu có thể sinh vài nghìn dòng log không lag + ngay nhưng không optimal. Sửa cần đổi scroll ownership (không phải 1 dòng). +2b. **Guided mode không nhớ theo từng recipe** — bấm "Mở chế độ nâng cao" rồi đóng, mở lại vẫn theo rule mặc + định (có id → Advanced). Bỏ có chủ đích (domain model không có chỗ lưu preference này); thêm khi có người + thực sự cần. +3. **4 mục pre-flight §10 không tự động verify được trong môi trường dev hiện tại** (thiếu fixture, không phải + app lỗi): + - Recipe thật end-to-end với `.pptx`/`.xlsx` thật (overlay khớp pixel) — không có fixture trong + `tests/fixtures/data/`. + - Multi-job progress đồng thời, và crash-resume (kill app giữa chừng) — cần fixture + chạy thật. + - Đo tương phản màu 4.5:1 — không có tool đo màu sẵn trong môi trường. + - Test đa độ phân giải (1024/1440/1920px) bằng ảnh chụp thật — chưa làm, ngoài phạm vi thời gian audit gần + nhất; code responsive theo token hệ thống nên rủi ro thấp nhưng chưa xác nhận bằng mắt. + +## Việc còn có thể làm tiếp (không phải nợ kỹ thuật, chỉ là mở rộng tương lai — xem §12 plan gốc "Đã cân nhắc và bỏ") + +Node/edge graph editor, wizard tách khỏi editor, 3-tab Studio, undo/redo stack, `INavigationService`, Rx/ +DynamicData, Svg.Skia, command palette, headless UI test, điều khiển cấp job, lịch sử row-level — tất cả đã cân +nhắc và bỏ có lý do rõ ràng trong plan gốc §12, không phải thiếu sót. Chỉ làm khi có nhu cầu thật xuất hiện. + +## Test & build + +``` +dotnet build SlideGenerator.slnx # phải xanh +dotnet test SlideGenerator.slnx # 523/524 (1 skip = thiếu SYNCFUSION_LICENSE_KEY) +dotnet test tests/SlideGenerator.Desktop.Tests/SlideGenerator.Desktop.Tests.csproj # 95/95 +``` + +Smoke test UI qua windows-mcp: xem git log các commit gần đây (`git log --oneline -20` trên branch `develop`) +để biết đúng những gì đã test bằng tay qua app thật (mở app, click qua các trang, gõ liệu thật) — mỗi commit +feat/fix của phase này đều có ghi chú smoke test chi tiết trong message hoặc trong plan gốc. diff --git a/qodana.yaml b/qodana.yaml new file mode 100644 index 00000000..b79e14d2 --- /dev/null +++ b/qodana.yaml @@ -0,0 +1,8 @@ +version: "1.0" +linter: jetbrains/qodana-cdnet:2026.1 + +dotnet: + solution: SlideGenerator.slnx + +profile: + name: qodana.recommended diff --git a/scripts/ApplyCopyright/ApplyCopyright.csproj b/scripts/ApplyCopyright/ApplyCopyright.csproj new file mode 100644 index 00000000..e3d3a3f8 --- /dev/null +++ b/scripts/ApplyCopyright/ApplyCopyright.csproj @@ -0,0 +1,17 @@ + + + Exe + net10.0 + enable + enable + ApplyCopyright + ApplyCopyright + false + + + + + + + + diff --git a/scripts/ApplyCopyright/Copyright.txt b/scripts/ApplyCopyright/Copyright.txt new file mode 100644 index 00000000..63c99608 --- /dev/null +++ b/scripts/ApplyCopyright/Copyright.txt @@ -0,0 +1,11 @@ +Copyright (C) {year} {author} + +Solution: {solution} +Project: {project} +File: {file} + +This file is part of this solution. +You can find the full source code here: {repoUrl}. + +Licensed under the Apache License 2.0. +See the LICENSE file in the project root for full license information. \ No newline at end of file diff --git a/scripts/ApplyCopyright/Program.cs b/scripts/ApplyCopyright/Program.cs new file mode 100644 index 00000000..b0d1efbc --- /dev/null +++ b/scripts/ApplyCopyright/Program.cs @@ -0,0 +1,179 @@ +/* + * Copyright (C) 2026 Thành Mai (thnhmai06) + * + * Solution: SlideGenerator + * Project: ApplyCopyright + * File: Program.cs + * + * This file is part of this solution. + * You can find the full source code here: https://github.com/thnhmai06/SlideGenerator. + * + * Licensed under the Apache License 2.0. + * See the LICENSE file in the project root for full license information. + */ + +using System.Text; +using System.Text.RegularExpressions; +using ApplyCopyright; + +const string year = "2026"; +const string author = "Thành Mai (thnhmai06)"; +const string solutionName = "SlideGenerator"; +const string repoUrl = "https://github.com/thnhmai06/SlideGenerator"; + +var options = Options.Parse(args); +var root = Path.GetFullPath(options.Root); +var files = Directory.EnumerateFiles(root, "*.cs", SearchOption.AllDirectories) + .Where(file => !IsIgnored(root, file)) + .Order(StringComparer.OrdinalIgnoreCase) + .ToArray(); + +var template = LoadTemplate(); +var changedCount = 0; + +foreach (var file in files) +{ + var projectName = GetProjectName(file) ?? solutionName; + var fileName = Path.GetFileName(file); + var content = await File.ReadAllTextAsync(file, Encoding.UTF8).ConfigureAwait(false); + var header = CreateHeader(template, projectName, fileName, DetectNewLine(content)); + var nextContent = header + RemoveExistingCopyrightHeaders(content); + var changed = content != nextContent; + + Console.WriteLine($"{(changed ? "Changed" : "Unchanged")} {file}"); + + if (!changed) continue; + + changedCount++; + + if (!options.Check) await File.WriteAllTextAsync(file, nextContent, new UTF8Encoding(false)).ConfigureAwait(false); +} + +Console.WriteLine(options.Check + ? $"Checked {files.Length} file(s). {changedCount} file(s) need updates." + : $"Scanned {files.Length} file(s). Updated {changedCount} file(s)."); + +return options.Check && changedCount > 0 ? 1 : 0; + +static string LoadTemplate() +{ + using var stream = typeof(Program).Assembly + .GetManifestResourceStream("ApplyCopyright.Copyright.txt")!; + using var reader = new StreamReader(stream, Encoding.UTF8); + return reader.ReadToEnd().TrimEnd('\r', '\n'); +} + +static string CreateHeader(string template, string projectName, string fileName, string newLine) +{ + var body = template + .Replace("{year}", year) + .Replace("{author}", author) + .Replace("{solution}", solutionName) + .Replace("{project}", projectName) + .Replace("{file}", fileName) + .Replace("{repoUrl}", repoUrl); + + var lines = body.ReplaceLineEndings("\n").Split('\n'); + var commented = lines.Select(l => string.IsNullOrEmpty(l) ? " *" : $" * {l}"); + return "/*" + newLine + string.Join(newLine, commented) + newLine + " */" + newLine + newLine; +} + +static string? GetProjectName(string filePath) +{ + var directory = Path.GetDirectoryName(filePath); + + while (!string.IsNullOrWhiteSpace(directory)) + { + var project = Directory.EnumerateFiles(directory, "*.csproj", SearchOption.TopDirectoryOnly) + .Order(StringComparer.OrdinalIgnoreCase) + .FirstOrDefault(); + + if (project is not null) return Path.GetFileNameWithoutExtension(project); + + var parent = Directory.GetParent(directory); + if (parent is null || string.Equals(parent.FullName, directory, StringComparison.OrdinalIgnoreCase)) break; + + directory = parent.FullName; + } + + return null; +} + +static string RemoveExistingCopyrightHeaders(string content) +{ + content = content.TrimStart('\uFEFF'); + while (TryStripCopyrightBlock(ref content) || TryStripCopyrightLine(ref content)) + { + } + + return content.TrimStart(); +} + +static bool TryStripCopyrightBlock(ref string content) +{ + var next = Regex.Replace( + content, + @"^\s*/\*[\s\S]*?\*/\s*", + match => match.Value.Contains("Copyright", StringComparison.OrdinalIgnoreCase) ? string.Empty : match.Value, + RegexOptions.None, + TimeSpan.FromSeconds(1)); + if (next == content) return false; + content = next; + return true; +} + +static bool TryStripCopyrightLine(ref string content) +{ + var next = Regex.Replace( + content, + @"^\s*(?://[^\r\n]*(?:\r?\n|$))+", + match => match.Value.Contains("Copyright", StringComparison.OrdinalIgnoreCase) ? string.Empty : match.Value, + RegexOptions.None, + TimeSpan.FromSeconds(1)); + if (next == content) return false; + content = next; + return true; +} + +static string DetectNewLine(string content) +{ + return content.Contains("\r\n", StringComparison.Ordinal) ? "\r\n" : "\n"; +} + +static bool IsIgnored(string root, string filePath) +{ + var relative = Path.GetRelativePath(root, filePath); + var segments = relative.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + + return segments.Contains("bin", StringComparer.OrdinalIgnoreCase) + || segments.Contains("obj", StringComparer.OrdinalIgnoreCase); +} + +namespace ApplyCopyright +{ + internal sealed record Options(string Root, bool Check) + { + public static Options Parse(string[] args) + { + var root = Directory.GetCurrentDirectory(); + var check = false; + + for (var i = 0; i < args.Length; i++) + switch (args[i]) + { + case "--check": + check = true; + break; + case "--root": + if (i + 1 >= args.Length) + throw new ArgumentException($"Unknown or incomplete argument: {args[i]}"); + root = args[++i]; + break; + default: + throw new ArgumentException($"Unknown or incomplete argument: {args[i]}"); + } + + return new Options(root, check); + } + } +} \ No newline at end of file diff --git a/src/SlideGenerator.Cloud/CloudClient.cs b/src/SlideGenerator.Cloud/CloudClient.cs new file mode 100644 index 00000000..0db2089a --- /dev/null +++ b/src/SlideGenerator.Cloud/CloudClient.cs @@ -0,0 +1,325 @@ +/* + * Copyright (C) 2026 Thành Mai (thnhmai06) + * + * Solution: SlideGenerator + * Project: SlideGenerator.Cloud + * File: CloudClient.cs + * + * This file is part of this solution. + * You can find the full source code here: https://github.com/thnhmai06/SlideGenerator. + * + * Licensed under the Apache License 2.0. + * See the LICENSE file in the project root for full license information. + */ + +using System.Collections.Immutable; +using System.Net; +using Microsoft.Extensions.Logging; +using SlideGenerator.Cloud.Resolvers; + +namespace SlideGenerator.Cloud; + +/// +/// Holds metadata about a remote resource obtained by inspecting its HTTP response headers. +/// +/// Final URI of the resource after following all HTTP redirects. +/// +/// MIME content-type (e.g. image/jpeg), or when the server did not +/// supply one. Only used to determine . +/// +/// Content length in bytes, or when unknown. +/// +/// File extension (including the leading dot, e.g. .jpg) taken directly from the +/// Content-Disposition file name or, failing that, from the URL path — never guessed from +/// . when neither source yields one. +/// +public record ContentInfo(Uri Uri, string? MimeType, uint? Length, string? Extension) +{ + /// + /// Returns when starts with image/ + /// (case-insensitive), indicating the resource is an image. + /// Returns when is . + /// + public bool IsImage() + { + return MimeType?.StartsWith("image/", StringComparison.OrdinalIgnoreCase) ?? false; + } +} + +/// +/// Performs HTTP-based cloud resource operations: content inspection and file download. +/// +public interface ICloudClient +{ + /// + /// Resolves to a direct download URI through a three-stage pipeline + /// and returns a record with the final URI, content-type, and + /// content-length. + /// + /// + /// HTTP redirect. Sends HEAD (falling back to GET on 405) and follows any + /// redirects to get the final URI. + /// + /// + /// Cloud resolution. If the final URI is recognized by a registered cloud + /// provider module (e.g., Google Drive), delegates to that module to produce a direct + /// download URI. When the module returns (e.g., empty folder, + /// inaccessible resource), the stage-1 URI is kept unchanged. + /// + /// + /// Re-inspection. Sends a second HEAD/GET to the resolved download URI so that + /// the returned reflects the actual content-type of the + /// downloadable resource rather than the sharing-page HTML. + /// + /// + /// Returns only when the initial HTTP request fails entirely + /// (network error, timeout, DNS failure). + /// + /// The starting URI to inspect. + /// + /// HTTP client used for all requests in the pipeline. When , a new + /// instance is created automatically with redirect-following enabled. + /// + /// Token to cancel the operation. + Task InspectAsync( + Uri uri, + HttpClient? httpClient = null, + CancellationToken cancellationToken = default); + + /// + /// Downloads the resource at and writes it to . + /// When is , a new instance is created + /// automatically with redirect-following enabled. + /// + Task DownloadAsync( + Uri uri, + string savePath, + HttpClient? httpClient = null, + CancellationToken cancellationToken = default); + + /// + /// Downloads the resource at and returns its content directly as a + /// byte array, without writing to disk. When is + /// , a new instance is created automatically with redirect-following + /// enabled. + /// + Task DownloadAsync( + Uri uri, + HttpClient? httpClient = null, + CancellationToken cancellationToken = default); +} + +/// +/// HTTP client facade that follows redirects, resolves cloud provider sharing links, +/// inspects resource metadata, and downloads files. +/// All methods accept an optional ; a new auto-redirect instance is +/// created automatically when is supplied. +/// +internal sealed class CloudClient(ILogger? logger = null) : ICloudClient +{ + private readonly ImmutableArray _resolvers = [new GoogleDriveResolver()]; + + /// + /// + /// Execution flow: + /// + /// + /// Sends a HEAD request (falling back to GET on 405) and follows any redirects to get + /// the final URI, content-type, and content-length. + /// + /// + /// Checks whether the final URI is handled by a registered cloud provider module + /// (e.g., Google Drive). If no module matches, returns the + /// from the first request unchanged. + /// + /// + /// When a module matches, delegates to it to produce a direct download URI. + /// If the module returns (e.g., empty folder, inaccessible + /// resource), the original final URI is returned unchanged. + /// + /// + /// Re-inspects the resolved download URI so that the returned + /// reflects the actual content-type and content-length of + /// the downloadable resource (not the sharing-page HTML). + /// + /// + /// Returns only when the initial HTTP request fails entirely + /// (network error, timeout, DNS failure). + /// + public async Task InspectAsync( + Uri uri, + HttpClient? httpClient = null, + CancellationToken cancellationToken = default) + { + httpClient ??= DefaultClient(); + logger?.LogDebug("HTTP inspect start | Uri: {Uri}", uri); + + // First Collect + var info = await CollectContentInfo(uri, httpClient, cancellationToken).ConfigureAwait(false); + if (info is null) + { + logger?.LogWarning("HTTP inspect failed, skipped | Uri: {Uri}", uri); + return null; + } + + // Resolve + var finalUri = info.Uri; + var resolver = FindResolver(finalUri); + if (resolver is null) + { + logger?.LogDebug("No cloud provider matched, return direct URI | Uri: {Uri}", finalUri); + return info; + } + + logger?.LogDebug("Cloud provider matched | Uri: {Uri}", finalUri); + + Uri? resolvedUri; + try + { + resolvedUri = await resolver.ResolveAsync(finalUri, httpClient, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + logger?.LogWarning(ex, "Cloud module resolve failed, returning final URI | Uri: {Uri}", finalUri); + return info; + } + + if (resolvedUri is null) + { + logger?.LogDebug("Cloud module returned null, return final URI | Uri: {Uri}", finalUri); + return info; + } + + // Second Collect + logger?.LogDebug("Cloud resolve completed, re-inspecting | ResolvedUri: {ResolvedUri}", resolvedUri); + return await CollectContentInfo(resolvedUri, httpClient, cancellationToken).ConfigureAwait(false); + } + + /// + /// + /// Streams the response body directly to , creating or + /// overwriting the file. The caller is responsible for ensuring the directory exists. + /// + public async Task DownloadAsync( + Uri uri, + string savePath, + HttpClient? httpClient = null, + CancellationToken cancellationToken = default) + { + savePath = Path.GetFullPath(savePath); + httpClient ??= DefaultClient(); + logger?.LogDebug("Download start | Uri: {Uri}, Path: {Path}", uri, savePath); + + await using var stream = await httpClient + .GetStreamAsync(uri, cancellationToken) + .ConfigureAwait(false); + await using var fs = new FileStream(savePath, FileMode.Create, FileAccess.Write, FileShare.None); + await stream.CopyToAsync(fs, cancellationToken).ConfigureAwait(false); + + logger?.LogDebug("Download completed | Uri: {Uri}, Path: {Path}", uri, savePath); + } + + /// + /// + /// Streams the response body into an in-memory buffer — no file is created. + /// + public async Task DownloadAsync( + Uri uri, + HttpClient? httpClient = null, + CancellationToken cancellationToken = default) + { + httpClient ??= DefaultClient(); + logger?.LogDebug("Download start (in-memory) | Uri: {Uri}", uri); + + var bytes = await httpClient.GetByteArrayAsync(uri, cancellationToken).ConfigureAwait(false); + + logger?.LogDebug("Download completed (in-memory) | Uri: {Uri}", uri); + return bytes; + } + + #region Private helpers + + /// + /// Sends a HEAD request to (falling back to GET on 405) and returns + /// a with the final URI, content-type, and content-length. + /// Returns on any exception. + /// + private static async Task CollectContentInfo( + Uri uri, + HttpClient httpClient, + CancellationToken cancellationToken) + { + try + { + // HEAD + var headResp = await httpClient + .SendAsync(new HttpRequestMessage(HttpMethod.Head, uri), cancellationToken) + .ConfigureAwait(false); + + // GET fallback + HttpResponseMessage response; + if (headResp.StatusCode == HttpStatusCode.MethodNotAllowed) + { + headResp.Dispose(); + response = await httpClient + .GetAsync(uri, HttpCompletionOption.ResponseHeadersRead, cancellationToken) + .ConfigureAwait(false); + } + else + { + response = headResp; + } + + using (response) + { + var finalUri = response.RequestMessage?.RequestUri ?? uri; + var mimeType = response.Content.Headers.ContentType?.MediaType; + var rawLength = response.Content.Headers.ContentLength; + var length = rawLength is > 0 ? (uint)rawLength.Value : (uint?)null; + var extension = ExtractExtension(response, finalUri); + return new ContentInfo(finalUri, mimeType, length, extension); + } + } + catch + { + return null; + } + } + + /// + /// Extracts the file extension (with leading dot) from the response's Content-Disposition + /// file name, falling back to 's path when absent. Returns + /// when neither source yields an extension. + /// + private static string? ExtractExtension(HttpResponseMessage response, Uri finalUri) + { + var fileName = response.Content.Headers.ContentDisposition?.FileNameStar ?? + response.Content.Headers.ContentDisposition?.FileName; + if (!string.IsNullOrWhiteSpace(fileName)) + { + var trimmed = fileName.Trim('"'); + var extFromFileName = Path.GetExtension(trimmed); + if (!string.IsNullOrEmpty(extFromFileName)) return extFromFileName; + } + + var extFromUrl = Path.GetExtension(finalUri.AbsolutePath); + return string.IsNullOrEmpty(extFromUrl) ? null : extFromUrl; + } + + /// + /// Returns the first registered module that can handle , + /// or when none matches. + /// + private CloudResolver? FindResolver(Uri uri) + { + return _resolvers.FirstOrDefault(module => module.IsResolvable(uri)); + } + + /// Creates a new with auto-redirect enabled (the default). + private static HttpClient DefaultClient() + { + return new HttpClient(new HttpClientHandler { AllowAutoRedirect = true }); + } + + #endregion +} \ No newline at end of file diff --git a/src/SlideGenerator.Cloud/Registration.cs b/src/SlideGenerator.Cloud/Registration.cs new file mode 100644 index 00000000..779f73b3 --- /dev/null +++ b/src/SlideGenerator.Cloud/Registration.cs @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2026 Thành Mai (thnhmai06) + * + * Solution: SlideGenerator + * Project: SlideGenerator.Cloud + * File: Registration.cs + * + * This file is part of this solution. + * You can find the full source code here: https://github.com/thnhmai06/SlideGenerator. + * + * Licensed under the Apache License 2.0. + * See the LICENSE file in the project root for full license information. + */ + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace SlideGenerator.Cloud; + +/// +/// Registers collector services into the dependency injection container. +/// +public static class Registration +{ + /// The service collection to update. + extension(IServiceCollection services) + { + /// + /// Adds cloud resolver, HTTP client factory, and file collector services. + /// + /// The updated service collection. + public IServiceCollection AddCloudServices() + { + services.AddSingleton(sp => + new CloudClient(sp.GetService>())); + return services; + } + } +} \ No newline at end of file diff --git a/src/SlideGenerator.Cloud/Resolvers/CloudResolver.cs b/src/SlideGenerator.Cloud/Resolvers/CloudResolver.cs new file mode 100644 index 00000000..1861c108 --- /dev/null +++ b/src/SlideGenerator.Cloud/Resolvers/CloudResolver.cs @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2026 Thành Mai (thnhmai06) + * + * Solution: SlideGenerator + * Project: SlideGenerator.Cloud + * File: CloudResolver.cs + * + * This file is part of this solution. + * You can find the full source code here: https://github.com/thnhmai06/SlideGenerator. + * + * Licensed under the Apache License 2.0. + * See the LICENSE file in the project root for full license information. + */ + +namespace SlideGenerator.Cloud.Resolvers; + +/// +/// Defines the contract for a single provider resolver module. +/// Modules convert provider-specific sharing links into direct download URIs. +/// Use for the public-facing composite client. +/// +internal abstract class CloudResolver +{ + /// + /// Returns when this module can handle . + /// + public abstract bool IsResolvable(Uri uri); + + /// + /// Resolves to a direct download URI, or returns + /// when no downloadable resource can be found + /// (e.g., permission denied, empty folder, non-existent file). + /// Callers must verify support via before calling this method. + /// + /// + /// Thrown when is not supported by this module. + /// + public abstract Task ResolveAsync( + Uri uri, + HttpClient httpClient, + CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/SlideGenerator.Cloud/Resolvers/GoogleDriveResolver.cs b/src/SlideGenerator.Cloud/Resolvers/GoogleDriveResolver.cs new file mode 100644 index 00000000..91b42c3e --- /dev/null +++ b/src/SlideGenerator.Cloud/Resolvers/GoogleDriveResolver.cs @@ -0,0 +1,137 @@ +/* + * Copyright (C) 2026 Thành Mai (thnhmai06) + * + * Solution: SlideGenerator + * Project: SlideGenerator.Cloud + * File: GoogleDriveResolver.cs + * + * This file is part of this solution. + * You can find the full source code here: https://github.com/thnhmai06/SlideGenerator. + * + * Licensed under the Apache License 2.0. + * See the LICENSE file in the project root for full license information. + */ + +using System.Text.RegularExpressions; +using System.Web; + +namespace SlideGenerator.Cloud.Resolvers; + +/// +/// Resolves Google Drive sharing links to direct download URIs. +/// Supports file links (/file/d/…), uc?id=… style links, and folder links. +/// For folder links, returns the download URI of the first direct-child file found; +/// returns when the folder is empty, contains only subfolders, +/// or is inaccessible. +/// +internal sealed partial class GoogleDriveResolver : CloudResolver +{ + private const string EmbeddedFolderViewBase = "https://drive.google.com/embeddedfolderview?id="; + private const string DownloadBase = "https://drive.google.com/uc?export=download&id="; + + /// + /// + /// Matches any URI whose host ends with drive.google.com (case-insensitive). + /// + public override bool IsResolvable(Uri uri) + { + return uri.Host.EndsWith("drive.google.com", StringComparison.OrdinalIgnoreCase); + } + + /// + /// + /// Resolution strategy: + /// + /// + /// /file/d/{id} — extracts file ID directly from the path and returns the + /// corresponding uc?export=download URI. + /// + /// + /// ?id={id} query parameter — treated as a direct file reference. + /// + /// + /// /folders/{id} — fetches the embedded folder view + /// (embeddedfolderview?id=…) and scans the HTML for the first + /// /file/d/ link. Returns when none is found or + /// when the request fails (e.g., HTTP 4xx/5xx, network error). + /// + /// + /// Returns when no file ID can be determined. + /// + public override async Task ResolveAsync( + Uri uri, + HttpClient httpClient, + CancellationToken cancellationToken = default) + { + if (!IsResolvable(uri)) + throw new ArgumentException( + $"URI '{uri}' is not supported by {nameof(GoogleDriveResolver)}.", nameof(uri)); + + string? fileId = null; + + if (uri.AbsolutePath.Contains("/file/d/")) + { + var match = FileIdInPathRegex().Match(uri.AbsoluteUri); + if (match.Success) + fileId = match.Groups[1].Value; + } + else if (uri.Query.Contains("id=")) + { + var query = HttpUtility.ParseQueryString(uri.Query); + fileId = query["id"]; + } + else if (uri.AbsolutePath.Contains("/folders/")) + { + var folderMatch = FolderIdInPathRegex().Match(uri.AbsolutePath); + if (folderMatch.Success) + fileId = await GetFirstFileIdFromFolderAsync( + folderMatch.Groups[1].Value, httpClient, cancellationToken).ConfigureAwait(false); + } + + return string.IsNullOrEmpty(fileId) ? null : new Uri(DownloadBase + fileId); + } + + #region Private helpers + + /// + /// Fetches the embedded folder view for and returns the ID of + /// the first direct-child file found in the HTML, or when no file + /// link is present or the request fails. + /// + private static async Task GetFirstFileIdFromFolderAsync( + string folderId, + HttpClient httpClient, + CancellationToken cancellationToken) + { + try + { + var html = await httpClient + .GetStringAsync(EmbeddedFolderViewBase + folderId, cancellationToken) + .ConfigureAwait(false); + var match = FileIdInHtmlRegex().Match(html); + return match.Success ? match.Groups[1].Value : null; + } + catch + { + return null; + } + } + + #endregion + + #region Regex + + /// Extracts a file ID from a /file/d/{id} URL segment. + [GeneratedRegex(@"/file/d/([^/?]+)", RegexOptions.Compiled)] + private static partial Regex FileIdInPathRegex(); + + /// Extracts a folder ID from a /folders/{id} URL segment. + [GeneratedRegex(@"/folders/([^/?]+)", RegexOptions.Compiled)] + private static partial Regex FolderIdInPathRegex(); + + /// Extracts a file ID from a /file/d/{id} link found in the HTML source. + [GeneratedRegex(@"/file/d/([^""'/?]+)", RegexOptions.Compiled)] + private static partial Regex FileIdInHtmlRegex(); + + #endregion +} \ No newline at end of file diff --git a/src/SlideGenerator.Cloud/SlideGenerator.Cloud.csproj b/src/SlideGenerator.Cloud/SlideGenerator.Cloud.csproj new file mode 100644 index 00000000..3bec5385 --- /dev/null +++ b/src/SlideGenerator.Cloud/SlideGenerator.Cloud.csproj @@ -0,0 +1,20 @@ + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + diff --git a/src/SlideGenerator.Cloud/Utilities.cs b/src/SlideGenerator.Cloud/Utilities.cs new file mode 100644 index 00000000..d17d0c89 --- /dev/null +++ b/src/SlideGenerator.Cloud/Utilities.cs @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2026 Thành Mai (thnhmai06) + * + * Solution: SlideGenerator + * Project: SlideGenerator.Cloud + * File: Utilities.cs + * + * This file is part of this solution. + * You can find the full source code here: https://github.com/thnhmai06/SlideGenerator. + * + * Licensed under the Apache License 2.0. + * See the LICENSE file in the project root for full license information. + */ + +using System.Diagnostics.CodeAnalysis; + +namespace SlideGenerator.Cloud; + +/// +/// Shared URI parsing utilities used across the Cloud module. +/// +internal static class Utilities +{ + /// + /// Tries to parse as an absolute . + /// Prepends https:// when contains no scheme separator (://). + /// + /// + /// A valid absolute , or when + /// is empty, whitespace, or cannot be parsed. + /// + public static bool TryCreateUri(string? url, [MaybeNullWhen(false)] out Uri uri) + { + uri = null; + + url = url?.Trim(); + if (string.IsNullOrWhiteSpace(url)) return false; + + if (!url.Contains("://")) url = Uri.UriSchemeHttps + "://" + url; + return Uri.TryCreate(url, UriKind.Absolute, out uri); + } + + /// + /// Tries to parse as an absolute . + /// Returns when parsing fails. + /// + public static Uri? TryCreateUri(string? url) + { + return TryCreateUri(url, out var uri) ? uri : null; + } +} \ No newline at end of file diff --git a/src/SlideGenerator.Desktop/App.axaml b/src/SlideGenerator.Desktop/App.axaml new file mode 100644 index 00000000..fae21489 --- /dev/null +++ b/src/SlideGenerator.Desktop/App.axaml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/SlideGenerator.Desktop/App.axaml.cs b/src/SlideGenerator.Desktop/App.axaml.cs new file mode 100644 index 00000000..12407c20 --- /dev/null +++ b/src/SlideGenerator.Desktop/App.axaml.cs @@ -0,0 +1,222 @@ +/* + * Copyright (C) 2026 Thành Mai (thnhmai06) + * + * Solution: SlideGenerator + * Project: SlideGenerator.Desktop + * File: App.axaml.cs + * + * This file is part of this solution. + * You can find the full source code here: https://github.com/thnhmai06/SlideGenerator. + * + * Licensed under the Apache License 2.0. + * See the LICENSE file in the project root for full license information. + */ + +using System.Diagnostics; +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Serilog; +using Serilog.Events; +using Serilog.Exceptions; +using SlideGenerator.Cloud; +using SlideGenerator.Desktop.Bootstrap; +using SlideGenerator.Desktop.Components; +using SlideGenerator.Desktop.Services.Localization; +using SlideGenerator.Desktop.Services.Progress; +using SlideGenerator.Desktop.Services.Theme; +using SlideGenerator.Desktop.Shell; +using SlideGenerator.Document; +using SlideGenerator.Generator; +using SlideGenerator.Image; +using SlideGenerator.Logging; +using SlideGenerator.Recipe; +using SlideGenerator.Settings; +using SlideGenerator.Settings.Immutable; +using SlideGenerator.Settings.Mutable; +using SlideGenerator.Summarizer; +using SlideGenerator.Utilities; + +namespace SlideGenerator.Desktop; + +/// +/// Avalonia application object. Builds the generic host (DI container for all domain modules), shows the +/// main window immediately, then runs startup work asynchronously without blocking the UI thread. +/// +public sealed class App : Application +{ + /// + /// If startup work finishes within this window, the splash is skipped entirely — showing it only to + /// immediately replace it reads as a flash, not a screen (see the plan's Startup section). + /// + private static readonly TimeSpan SplashSkipThreshold = TimeSpan.FromMilliseconds(400); + + private IHost? _host; + + /// + public override void Initialize() + { + AvaloniaXamlLoader.Load(this); + DataTemplates.Add(new ViewLocator()); + } + + /// + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + var builder = Host.CreateApplicationBuilder(new HostApplicationBuilderSettings + { + ContentRootPath = NameAndPaths.BasePath + }); + ConfigureServices(builder.Services); + _host = builder.Build(); + + var mainWindowViewModel = _host.Services.GetRequiredService(); + var window = new MainWindow { DataContext = mainWindowViewModel }; + desktop.MainWindow = window; + desktop.ShutdownRequested += (_, _) => ShutdownAsync(_host).GetAwaiter().GetResult(); + + // Fire-and-forget by design — OnFrameworkInitializationCompleted cannot be async, and awaiting + // here would reintroduce the exact UI-thread block this rewrite removes. Every awaited step below + // resumes back on the UI thread (Avalonia's SynchronizationContext), so DispatcherTimer-based + // services (IProgressHub) constructed partway through remain UI-thread-affine. A fire-and-forget + // Task's exception is otherwise swallowed silently (window would stay blank forever with no log + // line at all) — catch and log explicitly instead of letting that happen. + _ = StartupAsync(_host, mainWindowViewModel).ContinueWith( + t => Log.Fatal(t.Exception, "Startup failed"), + CancellationToken.None, TaskContinuationOptions.OnlyOnFaulted, TaskScheduler.Default); + + // Developer tools are attached via Program.cs's AppBuilder.WithDeveloperTools() instead of + // this.AttachDeveloperTools() here — the two are the same underlying mechanism, and calling + // both throws "Developer tools have already been attached." + } + + base.OnFrameworkInitializationCompleted(); + } + + private static async Task StartupAsync(IHost host, MainWindowViewModel mainWindowViewModel) + { + // Every await in this method (and everything it calls) must stay on the UI thread — ConfigureAwait(true) + // throughout, never (false) — because IProgressHub's DispatcherTimer is constructed partway through + // and every Avalonia call after that point (theme, CurrentContent) needs UI-thread affinity. + await host.StartAsync().ConfigureAwait(true); + + var sw = Stopwatch.StartNew(); + var initTask = InitializeAsync(host.Services); + var wonRace = await Task.WhenAny(initTask, Task.Delay(SplashSkipThreshold)).ConfigureAwait(true) == initTask; + + if (!wonRace) + { + // Startup is taking a while — show the splash and let its lockup animation play in full, even if + // init finishes before the animation would (an abrupt cut mid-transform looks broken). + mainWindowViewModel.CurrentContent = host.Services.GetRequiredService(); + await initTask.ConfigureAwait(true); + // ApplyFromSettings() already ran inside InitializeAsync above, so MotionBrand already reflects + // ReducedMotion — sizing the floor off BrandLockup's own total keeps the two in sync without + // duplicating its hold-before/animate/hold-after math here. + var motionBrand = ThemeService.GetMotionResource(Application.Current!, "MotionBrand"); + var minimumSplashDuration = BrandLockup.GetTotalDuration(motionBrand); + var remaining = minimumSplashDuration - sw.Elapsed; + if (remaining > TimeSpan.Zero) await Task.Delay(remaining).ConfigureAwait(true); + } + else + { + await initTask.ConfigureAwait(true); // propagate any exception; already resolved + } + + mainWindowViewModel.CurrentContent = host.Services.GetRequiredService(); + } + + private static void ConfigureServices(IServiceCollection services) + { + Log.Information("Registering Foundation services..."); + services.AddTransient(sp => + { + var cfg = sp.GetRequiredService(); + var level = cfg.GetValue("Logging:Workflow:MinimumLevel", LogEventLevel.Information); + return new LoggerConfiguration() + .MinimumLevel.Is(level) + .Enrich.FromLogContext() + .Enrich.WithExceptionDetails(); + }); + services.AddLoggingServices(); + services.AddSettingsServices(); + services.AddCloudServices(); + + Log.Information("Registering Domain services..."); + services.AddDocumentServices(); + services.AddImageServices(); + services.AddRecipeServices(); + services.AddSummarizationServices(); + + Log.Information("Registering Application services..."); + services.AddGeneratorServices(); + services.AddDesktopServices(); + } + + private static async Task InitializeAsync(IServiceProvider services) + { + Log.Information("Initializing application directories..."); + NameAndPaths.InitializeDirectories(); + Log.Information("Data DB: {Path}", NameAndPaths.DataFolder.DataFile.FilePath); + + var latestPath = Path.Combine(NameAndPaths.LogsFolder.SystemPath, "latest.log"); + var currentLogFiles = Directory.GetFiles(NameAndPaths.LogsFolder.SystemPath, "*.log") + .OrderByDescending(File.GetLastWriteTimeUtc) + .FirstOrDefault(); + if (currentLogFiles is not null) + try + { + HardLink.Create(latestPath, currentLogFiles); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + Log.Warning("Could not create 'latest.log' hard link: {Message}", ex.Message); + } + + // Must resolve (construct + subscribe) before IService.InitializeAsync() — crash-resumed jobs are + // scheduled immediately by JobRunner.InitializeAsync and their first progress events would be lost + // by a subscriber attached any later. See IProgressHub's remarks. + services.GetRequiredService(); + + var settingManager = services.GetRequiredService(); + Log.Information("Loading settings..."); + await settingManager.Load().ConfigureAwait(true); + + services.GetRequiredService().ApplyFromSettings(); + services.GetRequiredService().SetLanguage(settingManager.Current.Appearance.Language); + + var service = services.GetRequiredService(); + Log.Information("Starting job runner..."); + await service.InitializeAsync().ConfigureAwait(true); + + // Fire-and-forget — an update check must never hold up startup or the splash screen. + _ = UpdateChecker.CheckForUpdatesAsync(); + + Log.Information("Setup completed!"); + } + + private static async Task ShutdownAsync(IHost host) + { + try + { + var service = host.Services.GetRequiredService(); + await service.ShutdownAsync().ConfigureAwait(false); + + var settingManager = host.Services.GetRequiredService(); + await settingManager.Save().ConfigureAwait(false); + } + catch (Exception ex) + { + Log.Error(ex, "Error during shutdown."); + } + finally + { + await host.StopAsync().ConfigureAwait(false); + } + } +} \ No newline at end of file diff --git a/src/SlideGenerator.Desktop/Assets/Brand/app-icon.png b/src/SlideGenerator.Desktop/Assets/Brand/app-icon.png new file mode 100644 index 00000000..0a19b91a Binary files /dev/null and b/src/SlideGenerator.Desktop/Assets/Brand/app-icon.png differ diff --git a/src/SlideGenerator.Desktop/Assets/Brand/app-name.png b/src/SlideGenerator.Desktop/Assets/Brand/app-name.png new file mode 100644 index 00000000..268e7e69 Binary files /dev/null and b/src/SlideGenerator.Desktop/Assets/Brand/app-name.png differ diff --git a/src/SlideGenerator.Desktop/Assets/Fonts/JetBrainsMono-OFL.txt b/src/SlideGenerator.Desktop/Assets/Fonts/JetBrainsMono-OFL.txt new file mode 100644 index 00000000..8bee4148 --- /dev/null +++ b/src/SlideGenerator.Desktop/Assets/Fonts/JetBrainsMono-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/src/SlideGenerator.Desktop/Assets/Fonts/JetBrainsMono-Variable.ttf b/src/SlideGenerator.Desktop/Assets/Fonts/JetBrainsMono-Variable.ttf new file mode 100644 index 00000000..b60e77f5 Binary files /dev/null and b/src/SlideGenerator.Desktop/Assets/Fonts/JetBrainsMono-Variable.ttf differ diff --git a/src/SlideGenerator.Desktop/Assets/appicon.ico b/src/SlideGenerator.Desktop/Assets/appicon.ico new file mode 100644 index 00000000..1a4a2b47 Binary files /dev/null and b/src/SlideGenerator.Desktop/Assets/appicon.ico differ diff --git a/src/SlideGenerator.Desktop/Bootstrap/Metadata.cs b/src/SlideGenerator.Desktop/Bootstrap/Metadata.cs new file mode 100644 index 00000000..6c8950b1 --- /dev/null +++ b/src/SlideGenerator.Desktop/Bootstrap/Metadata.cs @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2026 Thành Mai (thnhmai06) + * + * Solution: SlideGenerator + * Project: SlideGenerator.Desktop + * File: Metadata.cs + * + * This file is part of this solution. + * You can find the full source code here: https://github.com/thnhmai06/SlideGenerator. + * + * Licensed under the Apache License 2.0. + * See the LICENSE file in the project root for full license information. + */ + +using System.Reflection; +using SlideGenerator.Settings.Immutable; + +namespace SlideGenerator.Desktop.Bootstrap; + +// Don't ask why. +internal static class Metadata +{ + public static class Print + { + public const string Description = "An automated, template-based presentation generator."; + + public const string Repository = + $"This software is FREE and OPEN-SOURCE. The source code is available here: {Value.Repository}"; + + public static readonly string License = + $"Copyright (c) {DateTime.Now.Year} {Value.Author}. Licensed under the {Value.License}."; + + /// The ASCII art representation of the application name. + public const string NameArt = + """ + /$$$$$$ /$$ /$$ /$$ /$$$$$$ /$$ + /$$__ $$| $$|__/ | $$ /$$__ $$ | $$ + | $$ \__/| $$ /$$ /$$$$$$$ /$$$$$$ | $$ \__/ /$$$$$$ /$$$$$$$ /$$$$$$ /$$$$$$ /$$$$$$ /$$$$$$ /$$$$$$ /$$$$$$ + | $$$$$$ | $$| $$ /$$__ $$ /$$__ $$| $$ /$$$$ /$$__ $$| $$__ $$ /$$__ $$ /$$__ $$|____ $$|_ $$_/ /$$__ $$ /$$__ $$ + \____ $$| $$| $$| $$ | $$| $$$$$$$$| $$|_ $$| $$$$$$$$| $$ \ $$| $$$$$$$$| $$ \__/ /$$$$$$$ | $$ | $$ \ $$| $$ \__/ + /$$ \ $$| $$| $$| $$ | $$| $$_____/| $$ \ $$| $$_____/| $$ | $$| $$_____/| $$ /$$__ $$ | $$ /$$| $$ | $$| $$ + | $$$$$$/| $$| $$| $$$$$$$| $$$$$$$| $$$$$$/| $$$$$$$| $$ | $$| $$$$$$$| $$ | $$$$$$$ | $$$$/| $$$$$$/| $$ + \______/ |__/|__/ \_______/ \_______/ \______/ \_______/|__/ |__/ \_______/|__/ \_______/ \___/ \______/ |__/ + """; + + public const string Portable = NameAndPaths.Portable ? "Portable" : "Installer"; + } + + public static class Value + { + /// The official application repository URL. + public const string Repository = "https://github.com/thnhmai06/SlideGenerator"; + + /// The official application author. + public const string Author = "Thành Mai (thnhmai06)"; + + /// The license under which the application is distributed. + public const string License = "Apache-2.0"; + + /// The running assembly's informational version (falls back to the assembly version, then + /// "unknown") — same lookup Program.PrintMetadata uses for the startup log line. + public static string Version + { + get + { + var assembly = typeof(Metadata).Assembly; + return assembly.GetCustomAttribute()?.InformationalVersion + ?? assembly.GetName().Version?.ToString() + ?? "unknown"; + } + } + } +} \ No newline at end of file diff --git a/src/SlideGenerator.Desktop/Bootstrap/SingleInstanceLock.cs b/src/SlideGenerator.Desktop/Bootstrap/SingleInstanceLock.cs new file mode 100644 index 00000000..f74bd626 --- /dev/null +++ b/src/SlideGenerator.Desktop/Bootstrap/SingleInstanceLock.cs @@ -0,0 +1,121 @@ +/* + * Copyright (C) 2026 Thành Mai (thnhmai06) + * + * Solution: SlideGenerator + * Project: SlideGenerator.Desktop + * File: SingleInstanceLock.cs + * + * This file is part of this solution. + * You can find the full source code here: https://github.com/thnhmai06/SlideGenerator. + * + * Licensed under the Apache License 2.0. + * See the LICENSE file in the project root for full license information. + */ + +namespace SlideGenerator.Desktop.Bootstrap; + +/// +/// Cross-platform single-instance guard. A named is the authoritative lock; +/// a PID file (kept write-locked) carries the owning process ID so a competing instance can display it. +/// Registered in DI as a singleton after succeeds in Program.cs. +/// +/// +/// OS mutex name — plain string, no Global\ prefix (that prefix breaks on Unix). +/// +/// Path where the owning process ID is written. +internal sealed class SingleInstanceLock(string mutexName, string pidFilePath) : IDisposable +{ + private readonly string _pidFilePath = Path.GetFullPath(pidFilePath); + private Mutex? _mutex; + private FileStream? _pidStream; + + /// + public void Dispose() + { + // PID + _pidStream?.Dispose(); + _pidStream = null; + try + { + if (File.Exists(_pidFilePath)) + File.Delete(_pidFilePath); + } + catch (IOException) + { + } + + // Mutex + if (_mutex is null) return; + try + { + _mutex.ReleaseMutex(); + } + catch (ApplicationException) + { + } + + _mutex.Dispose(); + _mutex = null; + } + + /// + /// Tries to acquire the named mutex. On success, writes the current process ID to + /// the PID file and holds the file open so only this process can write while readers can use compatible sharing. + /// + /// if the lock was acquired; if another instance holds it. + public bool TryAcquire() + { + ArgumentException.ThrowIfNullOrWhiteSpace(mutexName); + ArgumentException.ThrowIfNullOrWhiteSpace(pidFilePath); + + _mutex = new Mutex(false, mutexName); + try + { + if (!_mutex.WaitOne(0)) + { + _mutex.Dispose(); + _mutex = null; + return false; + } + } + catch (AbandonedMutexException) + { + // Previous owner crashed without releasing — we now own it. + } + + var directory = Path.GetDirectoryName(_pidFilePath); + if (!string.IsNullOrWhiteSpace(directory)) Directory.CreateDirectory(directory); + + _pidStream = new FileStream(_pidFilePath, FileMode.Create, FileAccess.ReadWrite, FileShare.Read); + using var writer = new StreamWriter(_pidStream, leaveOpen: true); + writer.Write(Environment.ProcessId); + writer.Flush(); + + return true; + } + + /// + /// Reads the PID from the PID file while the owner holds a write-exclusive lock. + /// Uses so it is compatible with the owner's . + /// Returns if the file cannot be read. + /// + public int? ReadPid() + { + try + { + var stream = _pidStream ?? + new FileStream(_pidFilePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + stream.Flush(); + stream.Seek(0, SeekOrigin.Begin); + using var reader = new StreamReader(stream, leaveOpen: _pidStream is not null); + var pid = int.Parse(reader.ReadToEnd()); + + if (_pidStream is null) stream.Dispose(); + return pid; + } + catch + { + return null; + } + } +} \ No newline at end of file diff --git a/src/SlideGenerator.Desktop/Bootstrap/UpdateChecker.cs b/src/SlideGenerator.Desktop/Bootstrap/UpdateChecker.cs new file mode 100644 index 00000000..8d014e25 --- /dev/null +++ b/src/SlideGenerator.Desktop/Bootstrap/UpdateChecker.cs @@ -0,0 +1,74 @@ +/* + * Copyright (C) 2026 Thành Mai (thnhmai06) + * + * Solution: SlideGenerator + * Project: SlideGenerator.Desktop + * File: UpdateChecker.cs + * + * This file is part of this solution. + * You can find the full source code here: https://github.com/thnhmai06/SlideGenerator. + * + * Licensed under the Apache License 2.0. + * See the LICENSE file in the project root for full license information. + */ + +using Serilog; +using Velopack; +using Velopack.Sources; + +namespace SlideGenerator.Desktop.Bootstrap; + +/// The outcome of — lets a caller with a UI (the +/// Settings page's "Kiểm tra cập nhật" button) show what happened, while the startup fire-and-forget call +/// keeps ignoring the result. +internal enum UpdateCheckResult +{ + /// Not running as an installed Velopack app (e.g. `dotnet run`, portable) — nothing to check. + NotInstalled, + + /// Already on the latest version. + UpToDate, + + /// A newer version was downloaded; applies on next restart. + UpdateDownloaded, + + /// The check or download failed (network error, GitHub unreachable, etc.). + Failed +} + +/// +/// Checks GitHub Releases for a newer version via Velopack. +/// +internal static class UpdateChecker +{ + /// Checks for updates, logs the outcome, and returns it for a caller that wants to show it. + public static async Task CheckForUpdatesAsync() + { + var manager = new UpdateManager(new GithubSource(Metadata.Value.Repository, null, false)); + if (!manager.IsInstalled) + { + Log.Debug("Skipping update check: not running as an installed Velopack app."); + return UpdateCheckResult.NotInstalled; + } + + try + { + var newVersion = await manager.CheckForUpdatesAsync().ConfigureAwait(false); + if (newVersion is null) + { + Log.Information("No update available. Current version: {Version}", manager.CurrentVersion); + return UpdateCheckResult.UpToDate; + } + + Log.Information("Update available: {Version}. Downloading...", newVersion.TargetFullRelease.Version); + await manager.DownloadUpdatesAsync(newVersion).ConfigureAwait(false); + Log.Information("Update downloaded. Will apply on next restart."); + return UpdateCheckResult.UpdateDownloaded; + } + catch (Exception ex) + { + Log.Warning(ex, "Update check failed."); + return UpdateCheckResult.Failed; + } + } +} \ No newline at end of file diff --git a/src/SlideGenerator.Desktop/Components/BrandLockup.axaml b/src/SlideGenerator.Desktop/Components/BrandLockup.axaml new file mode 100644 index 00000000..b317ae0e --- /dev/null +++ b/src/SlideGenerator.Desktop/Components/BrandLockup.axaml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + diff --git a/src/SlideGenerator.Desktop/Components/BrandLockup.axaml.cs b/src/SlideGenerator.Desktop/Components/BrandLockup.axaml.cs new file mode 100644 index 00000000..6ac642a6 --- /dev/null +++ b/src/SlideGenerator.Desktop/Components/BrandLockup.axaml.cs @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2026 Thành Mai (thnhmai06) + * + * Solution: SlideGenerator + * Project: SlideGenerator.Desktop + * File: BrandLockup.axaml.cs + * + * This file is part of this solution. + * You can find the full source code here: https://github.com/thnhmai06/SlideGenerator. + * + * Licensed under the Apache License 2.0. + * See the LICENSE file in the project root for full license information. + */ + +using Avalonia; +using Avalonia.Controls; +using SlideGenerator.Desktop.Services.Theme; + +namespace SlideGenerator.Desktop.Components; + +/// +/// Reusable brand mark + wordmark reveal, extracted from the original single-use Splash animation so +/// About (blueprint §5.7, P6) can replay the same identity moment on every visit. Implements the +/// "logo → animation → full → hold" sequence from the product brief as four real, sequential stages — +/// the original Splash-only version played stages 2/3 as one simultaneous 400ms beat with no explicit +/// stage 1 or 4 (the "hold" was faked entirely by App.axaml.cs's wall-clock splash-duration floor). +/// +public sealed partial class BrandLockup : UserControl +{ + private static readonly TimeSpan BeforeHold = TimeSpan.FromMilliseconds(150); + private static readonly TimeSpan AfterHold = TimeSpan.FromMilliseconds(350); + + /// + /// The full wall-clock duration one call takes for a given MotionBrand + /// value — App.axaml.cs uses this to size Splash's minimum visible duration, so the + /// hold-before/animate/hold-after sequence is never cut short by the shell swapping in underneath it. + /// Zero when is zero (reduced motion), matching + /// skipping both holds in that case. + /// + public static TimeSpan GetTotalDuration(TimeSpan motionBrand) + { + return motionBrand == TimeSpan.Zero ? TimeSpan.Zero : BeforeHold + motionBrand + AfterHold; + } + + /// Constructs the control and loads its XAML. + public BrandLockup() + { + InitializeComponent(); + } + + /// + /// Plays the four-stage reveal — (1) icon alone, held briefly; (2) mark slides left while the + /// wordmark fades/slides in; (3) full lockup settled; (4) held briefly — then returns. Safe to call + /// again (e.g. About re-entering the page): resets to stage 1 first, so a rapid re-entry replays + /// cleanly instead of skipping frames mid-transition. + /// + public async Task PlayAsync(CancellationToken ct = default) + { + MarkImage.Classes.Remove("revealed"); + WordImage.Classes.Remove("revealed"); + + var duration = ThemeService.GetMotionResource(Application.Current!, "MotionBrand"); + var reduced = duration == TimeSpan.Zero; + + // Stage 1 — icon alone. Also gives the "not revealed" state at least one render pass to land before + // the transition below has to animate away from it (Avalonia processes pending layout/render while a + // Task.Delay yields, same effect as the old Dispatcher.Post(..., DispatcherPriority.Loaded) trick). + if (!reduced) await Task.Delay(BeforeHold, ct).ConfigureAwait(true); + + // Stage 2 — animate. Stage 3 — full (the transition's own end state, landed on once Duration elapses). + MarkImage.Classes.Add("revealed"); + WordImage.Classes.Add("revealed"); + if (!reduced) await Task.Delay(duration, ct).ConfigureAwait(true); + + // Stage 4 — hold. + if (!reduced) await Task.Delay(AfterHold, ct).ConfigureAwait(true); + } +} diff --git a/src/SlideGenerator.Desktop/Converters/BindingSummaryConverter.cs b/src/SlideGenerator.Desktop/Converters/BindingSummaryConverter.cs new file mode 100644 index 00000000..e73079c8 --- /dev/null +++ b/src/SlideGenerator.Desktop/Converters/BindingSummaryConverter.cs @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2026 Thành Mai (thnhmai06) + * + * Solution: SlideGenerator + * Project: SlideGenerator.Desktop + * File: BindingSummaryConverter.cs + * + * This file is part of this solution. + * You can find the full source code here: https://github.com/thnhmai06/SlideGenerator. + * + * Licensed under the Apache License 2.0. + * See the LICENSE file in the project root for full license information. + */ + +using System.Globalization; +using Avalonia.Data.Converters; +using SlideGenerator.Desktop.Services.Localization; + +namespace SlideGenerator.Desktop.Converters; + +/// +/// Formats a of (Assigned, Suggested, NeedsSelection, Unassigned) +/// counts — TextBindingsViewModel.Summary/SlideCanvasViewModel's equivalent — into plan +/// §5.2's "12 đã ghép · 3 là đề xuất · 2 cần bạn chọn · 1 chưa gán" summary line. +/// +public sealed class BindingSummaryConverter : IValueConverter +{ + /// Gets the shared instance — this converter has no state, so one instance serves the whole app. + public static readonly BindingSummaryConverter Instance = new(); + + /// + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is not (int assigned, int suggested, int needsSelection, int unassigned)) return null; + return string.Format(LocalizationService.Instance["recipeEditor.bindingSummary"], + assigned, suggested, needsSelection, unassigned); + } + + /// + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotSupportedException(); + } +} diff --git a/src/SlideGenerator.Desktop/Converters/EnumEqualsConverter.cs b/src/SlideGenerator.Desktop/Converters/EnumEqualsConverter.cs new file mode 100644 index 00000000..cda9768e --- /dev/null +++ b/src/SlideGenerator.Desktop/Converters/EnumEqualsConverter.cs @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2026 Thành Mai (thnhmai06) + * + * Solution: SlideGenerator + * Project: SlideGenerator.Desktop + * File: EnumEqualsConverter.cs + * + * This file is part of this solution. + * You can find the full source code here: https://github.com/thnhmai06/SlideGenerator. + * + * Licensed under the Apache License 2.0. + * See the LICENSE file in the project root for full license information. + */ + +using System.Globalization; +using Avalonia.Data.Converters; + +namespace SlideGenerator.Desktop.Converters; + +/// +/// Compares a bound enum value against ConverterParametertrue when equal. Used for +/// highlighting the active choice in a chip/radio group without a boolean property per enum value (see +/// Runs' status filter, Settings' theme picker). ConvertBack lets the same binding drive a +/// two-way toggle (e.g. a RadioButton.IsChecked) that sets the source property to the parameter +/// value when checked. +/// +public sealed class EnumEqualsConverter : IValueConverter +{ + /// Gets the shared instance — this converter has no state, so one instance serves the whole app. + public static readonly EnumEqualsConverter Instance = new(); + + /// + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + return value is not null && parameter is not null && value.Equals(parameter); + } + + /// + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + return value is true ? parameter : Avalonia.Data.BindingOperations.DoNothing; + } +} + +/// The negation of true when the bound value differs from ConverterParameter. +public sealed class EnumNotEqualsConverter : IValueConverter +{ + /// Gets the shared instance — this converter has no state, so one instance serves the whole app. + public static readonly EnumNotEqualsConverter Instance = new(); + + /// + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + return value is not null && parameter is not null && !value.Equals(parameter); + } + + /// + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotSupportedException(); + } +} diff --git a/src/SlideGenerator.Desktop/Converters/EnumLocalizedTextConverter.cs b/src/SlideGenerator.Desktop/Converters/EnumLocalizedTextConverter.cs new file mode 100644 index 00000000..4e237d72 --- /dev/null +++ b/src/SlideGenerator.Desktop/Converters/EnumLocalizedTextConverter.cs @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2026 Thành Mai (thnhmai06) + * + * Solution: SlideGenerator + * Project: SlideGenerator.Desktop + * File: EnumLocalizedTextConverter.cs + * + * This file is part of this solution. + * You can find the full source code here: https://github.com/thnhmai06/SlideGenerator. + * + * Licensed under the Apache License 2.0. + * See the LICENSE file in the project root for full license information. + */ + +using System.Collections.Generic; +using System.Globalization; +using Avalonia.Data.Converters; +using SlideGenerator.Desktop.Services.Localization; + +namespace SlideGenerator.Desktop.Converters; + +/// +/// Localizes an enum value via enums.{ConverterParameter}.{camelCasedMemberName} resource keys (e.g. +/// JobStatus.Running with parameter "jobStatus"enums.jobStatus.running). Takes a +/// of (the enum value, ) +/// rather than a plain — the same live-refresh requirement +/// solves by binding to : the enum value itself does not change when +/// is called, so a plain single-value converter would never +/// re-run on a language switch. +/// +public sealed class EnumLocalizedTextConverter : IMultiValueConverter +{ + /// Gets the shared instance — this converter has no state, so one instance serves the whole app. + public static readonly EnumLocalizedTextConverter Instance = new(); + + /// + public object? Convert(IList values, Type targetType, object? parameter, CultureInfo culture) + { + if (values.Count == 0 || values[0] is not { } enumValue || parameter is not string area) return null; + var name = enumValue.ToString()!; + var camel = char.ToLowerInvariant(name[0]) + name[1..]; + return LocalizationService.Instance[$"enums.{area}.{camel}"]; + } +} diff --git a/src/SlideGenerator.Desktop/Converters/FirstLetterUpperConverter.cs b/src/SlideGenerator.Desktop/Converters/FirstLetterUpperConverter.cs new file mode 100644 index 00000000..6276fac1 --- /dev/null +++ b/src/SlideGenerator.Desktop/Converters/FirstLetterUpperConverter.cs @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2026 Thành Mai (thnhmai06) + * + * Solution: SlideGenerator + * Project: SlideGenerator.Desktop + * File: FirstLetterUpperConverter.cs + * + * This file is part of this solution. + * You can find the full source code here: https://github.com/thnhmai06/SlideGenerator. + * + * Licensed under the Apache License 2.0. + * See the LICENSE file in the project root for full license information. + */ + +using System.Globalization; +using Avalonia.Data.Converters; + +namespace SlideGenerator.Desktop.Converters; + +/// +/// Extracts the first character of a string, upper-cased — the initials-circle placeholder for a +/// Developer/Supporter row (plan §5.7: "avatar = initials circle") — used unconditionally rather than +/// only as an offline fallback, since downloading and caching real avatar images is its own subsystem +/// this phase doesn't build (ponytail: add real avatars if this is ever felt as a real gap). +/// +public sealed class FirstLetterUpperConverter : IValueConverter +{ + /// Gets the shared instance — this converter has no state, so one instance serves the whole app. + public static readonly FirstLetterUpperConverter Instance = new(); + + /// + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + return value is string { Length: > 0 } s ? char.ToUpperInvariant(s[0]).ToString() : "?"; + } + + /// + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotSupportedException(); + } +} diff --git a/src/SlideGenerator.Desktop/Converters/LocalizedTextConverter.cs b/src/SlideGenerator.Desktop/Converters/LocalizedTextConverter.cs new file mode 100644 index 00000000..396ee048 --- /dev/null +++ b/src/SlideGenerator.Desktop/Converters/LocalizedTextConverter.cs @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2026 Thành Mai (thnhmai06) + * + * Solution: SlideGenerator + * Project: SlideGenerator.Desktop + * File: LocalizedTextConverter.cs + * + * This file is part of this solution. + * You can find the full source code here: https://github.com/thnhmai06/SlideGenerator. + * + * Licensed under the Apache License 2.0. + * See the LICENSE file in the project root for full license information. + */ + +using System.Globalization; +using Avalonia.Data.Converters; +using SlideGenerator.Desktop.Services.Localization; + +namespace SlideGenerator.Desktop.Converters; + +/// +/// Ignores the bound value entirely and looks up ConverterParameter as a resource key via +/// . Used by , which binds to +/// (a plain named property) purely to trigger this convert +/// call on every — see 's doc +/// comment for why an indexer binding does not live-refresh in this app's compiled-binding pipeline. +/// +public sealed class LocalizedTextConverter : IValueConverter +{ + /// Gets the shared instance — this converter has no state, so one instance serves the whole app. + public static readonly LocalizedTextConverter Instance = new(); + + /// + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + return parameter is string key ? LocalizationService.Instance[key] : null; + } + + /// + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotSupportedException(); + } +} diff --git a/src/SlideGenerator.Desktop/Converters/LogLevelBrushConverter.cs b/src/SlideGenerator.Desktop/Converters/LogLevelBrushConverter.cs new file mode 100644 index 00000000..74443159 --- /dev/null +++ b/src/SlideGenerator.Desktop/Converters/LogLevelBrushConverter.cs @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2026 Thành Mai (thnhmai06) + * + * Solution: SlideGenerator + * Project: SlideGenerator.Desktop + * File: LogLevelBrushConverter.cs + * + * This file is part of this solution. + * You can find the full source code here: https://github.com/thnhmai06/SlideGenerator. + * + * Licensed under the Apache License 2.0. + * See the LICENSE file in the project root for full license information. + */ + +using System.Globalization; +using Avalonia; +using Avalonia.Data.Converters; +using Avalonia.Media; + +namespace SlideGenerator.Desktop.Converters; + +/// +/// Colors a log line by its 3-letter level abbreviation ("WRN"/"ERR", see +/// FileLogFormatter) — plan §5.4: "Log pane: ... màu WRN/ERR". Anything else (typically +/// "INF") keeps the default text color by returning , letting the bound +/// property fall through to its own default rather than this converter hardcoding a "normal" brush. +/// +public sealed class LogLevelBrushConverter : IValueConverter +{ + /// Gets the shared instance — this converter has no state, so one instance serves the whole app. + public static readonly LogLevelBrushConverter Instance = new(); + + /// + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + var key = (value as string) switch + { + "WRN" => "WarningBrush", + "ERR" => "DangerBrush", + _ => null + }; + return key is not null && Application.Current!.TryGetResource(key, Application.Current.ActualThemeVariant, out var brush) + ? brush as IBrush + : null; + } + + /// + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotSupportedException(); + } +} diff --git a/src/SlideGenerator.Desktop/Converters/RoiDescriptionConverter.cs b/src/SlideGenerator.Desktop/Converters/RoiDescriptionConverter.cs new file mode 100644 index 00000000..f99864d5 --- /dev/null +++ b/src/SlideGenerator.Desktop/Converters/RoiDescriptionConverter.cs @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2026 Thành Mai (thnhmai06) + * + * Solution: SlideGenerator + * Project: SlideGenerator.Desktop + * File: RoiDescriptionConverter.cs + * + * This file is part of this solution. + * You can find the full source code here: https://github.com/thnhmai06/SlideGenerator. + * + * Licensed under the Apache License 2.0. + * See the LICENSE file in the project root for full license information. + */ + +using System.Collections.Generic; +using System.Globalization; +using Avalonia.Data.Converters; +using SlideGenerator.Desktop.Services.Localization; +using SlideGenerator.Image.Cropping; + +namespace SlideGenerator.Desktop.Converters; + +/// +/// Explains one in plain language (plan §5.3, image 26: "ROI cần giải thích trực +/// quan trong inspector" — resolved as short text rather than a drawn illustration) — e.g. an +/// with reads "Anchor point: Face center". Takes +/// a of (the , +/// ) for the same live-refresh reason as +/// — the bound object doesn't change on a language switch, only +/// the strings looked up from it do. +/// +public sealed class RoiDescriptionConverter : IMultiValueConverter +{ + /// Gets the shared instance — this converter has no state, so one instance serves the whole app. + public static readonly RoiDescriptionConverter Instance = new(); + + /// + public object? Convert(IList values, Type targetType, object? parameter, CultureInfo culture) + { + var loc = LocalizationService.Instance; + return values.Count > 0 ? values[0] switch + { + AnchorOption a => $"{loc["enums.roiMode.anchor"]}: {loc[$"enums.anchorType.{Camel(a.Type.ToString())}"]}", + InterestOption i => $"{loc["enums.roiMode.interest"]}: {loc[$"enums.interestType.{Camel(i.Type.ToString())}"]}", + _ => "" + } : ""; + } + + private static string Camel(string name) + { + return char.ToLowerInvariant(name[0]) + name[1..]; + } +} diff --git a/src/SlideGenerator.Desktop/Features/About/Models/AboutModels.cs b/src/SlideGenerator.Desktop/Features/About/Models/AboutModels.cs new file mode 100644 index 00000000..7b6241ac --- /dev/null +++ b/src/SlideGenerator.Desktop/Features/About/Models/AboutModels.cs @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2026 Thành Mai (thnhmai06) + * + * Solution: SlideGenerator + * Project: SlideGenerator.Desktop + * File: AboutModels.cs + * + * This file is part of this solution. + * You can find the full source code here: https://github.com/thnhmai06/SlideGenerator. + * + * Licensed under the Apache License 2.0. + * See the LICENSE file in the project root for full license information. + */ + +namespace SlideGenerator.Desktop.Features.About.Models; + +/// +/// One GitHub contributor to the repository (plan §5.7 "Developers") — sourced live from the GitHub REST +/// API, never fabricated. No role/badge field: the plan's crown/computer/paint role icons need an +/// official login→role map from the project owner (blueprint §8-Q2, not yet answered) — until then this +/// shows plain contributor identity only. +/// +public sealed record Contributor(string Login, string AvatarUrl, string ProfileUrl, int Contributions); + +/// One GitHub Sponsor of the repository owner (plan §5.7 "Supporters") — sourced from the +/// sponsors.json a scheduled GitHub Action publishes (see .github/workflows/sponsors.yml), +/// never fabricated. +public sealed record Supporter(string Login, string AvatarUrl, string ProfileUrl); diff --git a/src/SlideGenerator.Desktop/Features/About/Services/IAboutDataService.cs b/src/SlideGenerator.Desktop/Features/About/Services/IAboutDataService.cs new file mode 100644 index 00000000..e4676f5c --- /dev/null +++ b/src/SlideGenerator.Desktop/Features/About/Services/IAboutDataService.cs @@ -0,0 +1,138 @@ +/* + * Copyright (C) 2026 Thành Mai (thnhmai06) + * + * Solution: SlideGenerator + * Project: SlideGenerator.Desktop + * File: IAboutDataService.cs + * + * This file is part of this solution. + * You can find the full source code here: https://github.com/thnhmai06/SlideGenerator. + * + * Licensed under the Apache License 2.0. + * See the LICENSE file in the project root for full license information. + */ + +using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; +using Serilog; +using SlideGenerator.Desktop.Bootstrap; +using SlideGenerator.Desktop.Features.About.Models; +using SlideGenerator.Settings.Immutable; + +namespace SlideGenerator.Desktop.Features.About.Services; + +/// +/// Fetches the About page's two live-data lists (plan §5.7): repository contributors from the GitHub +/// REST API, and sponsors from a sponsors.json file a scheduled GitHub Action publishes (see +/// .github/workflows/sponsors.yml) — GitHub Sponsors has no public unauthenticated REST endpoint, +/// so the app can't query it directly. Both calls are disk-cached (24h TTL) so the About page doesn't +/// re-fetch on every visit within the same day, and both fail soft to an empty list — a network problem +/// must never make the About page itself show an error (plan: "About không bao giờ lỗi đỏ"). +/// +public interface IAboutDataService +{ + /// Gets every contributor to the repository, most contributions first, or an empty list if the + /// API is unreachable and no cache exists yet. + Task> GetContributorsAsync(CancellationToken ct = default); + + /// Gets every current GitHub Sponsor, or an empty list if sponsors.json doesn't exist yet + /// (no sponsors, or the publishing workflow hasn't run) or is unreachable. + Task> GetSupportersAsync(CancellationToken ct = default); +} + +/// +public sealed class AboutDataService : IAboutDataService +{ + private const string RepoOwner = "thnhmai06"; + private const string RepoName = "SlideGenerator"; + private static readonly TimeSpan CacheTtl = TimeSpan.FromHours(24); + + private static readonly HttpClient Http = new() + { + DefaultRequestHeaders = { UserAgent = { ProductInfoHeaderValue.Parse($"{RepoName}/{Metadata.Value.Version}") } } + }; + + /// + public async Task> GetContributorsAsync(CancellationToken ct = default) + { + return await FetchCachedAsync>( + "about-contributors.json", + async () => + { + var rows = await Http.GetFromJsonAsync>( + $"https://api.github.com/repos/{RepoOwner}/{RepoName}/contributors", ct).ConfigureAwait(false); + return (IReadOnlyList)(rows ?? []) + .OrderByDescending(r => r.Contributions) + .Select(r => new Contributor(r.Login, r.AvatarUrl, r.HtmlUrl, r.Contributions)) + .ToList(); + }, ct).ConfigureAwait(false) ?? []; + } + + /// + public async Task> GetSupportersAsync(CancellationToken ct = default) + { + return await FetchCachedAsync>( + "about-sponsors.json", + async () => + { + // 404 (workflow hasn't run yet, or no sponsors) is expected, not an error — GetAsync below + // treats it the same as any other failure: fall through to an empty list. + var response = await Http.GetAsync( + $"https://raw.githubusercontent.com/{RepoOwner}/{RepoName}/data/sponsors.json", ct).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) return []; + var rows = await response.Content.ReadFromJsonAsync>(ct).ConfigureAwait(false); + return (IReadOnlyList)(rows ?? []); + }, ct).ConfigureAwait(false) ?? []; + } + + /// Returns the cached value at if written within + /// ; otherwise calls , caching a successful result. A + /// failed fetch falls back to a stale cache if one exists, then to (an + /// empty list, from each caller's own ?? []) — a fetch failure must never surface as an + /// exception to the ViewModel. + private static async Task FetchCachedAsync(string cacheFileName, Func> fetch, CancellationToken ct) + { + var path = Path.Combine(NameAndPaths.DataFolder.FolderPath, cacheFileName); + var cached = TryReadCache(path); + if (cached is { Age: var age } && age < CacheTtl) return cached.Value.Value; + + try + { + var fresh = await fetch().ConfigureAwait(false); + Directory.CreateDirectory(NameAndPaths.DataFolder.FolderPath); + await File.WriteAllTextAsync(path, + JsonSerializer.Serialize(new CacheEnvelope(DateTimeOffset.UtcNow, fresh)), ct).ConfigureAwait(false); + return fresh; + } + catch (Exception ex) when (ex is HttpRequestException or JsonException or TaskCanceledException) + { + Log.Warning(ex, "About page data fetch failed for {CacheFile}; falling back to stale/empty.", cacheFileName); + return cached is { } stale ? stale.Value : default; + } + } + + private static (T Value, TimeSpan Age)? TryReadCache(string path) + { + try + { + if (!File.Exists(path)) return null; + var envelope = JsonSerializer.Deserialize>(File.ReadAllText(path)); + return envelope is null ? null : (envelope.Data, DateTimeOffset.UtcNow - envelope.FetchedAt); + } + catch (Exception ex) when (ex is IOException or JsonException) + { + return null; // corrupt/unreadable cache — treat as no cache, re-fetch + } + } + + private sealed record CacheEnvelope(DateTimeOffset FetchedAt, T Data); + + private sealed record ContributorRow( + string Login, + [property: JsonPropertyName("avatar_url")] string AvatarUrl, + [property: JsonPropertyName("html_url")] string HtmlUrl, + int Contributions); +} diff --git a/src/SlideGenerator.Desktop/Features/About/ViewModels/AboutViewModel.cs b/src/SlideGenerator.Desktop/Features/About/ViewModels/AboutViewModel.cs new file mode 100644 index 00000000..da73553b --- /dev/null +++ b/src/SlideGenerator.Desktop/Features/About/ViewModels/AboutViewModel.cs @@ -0,0 +1,150 @@ +/* + * Copyright (C) 2026 Thành Mai (thnhmai06) + * + * Solution: SlideGenerator + * Project: SlideGenerator.Desktop + * File: AboutViewModel.cs + * + * This file is part of this solution. + * You can find the full source code here: https://github.com/thnhmai06/SlideGenerator. + * + * Licensed under the Apache License 2.0. + * See the LICENSE file in the project root for full license information. + */ + +using System.Collections.ObjectModel; +using System.Diagnostics; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using SlideGenerator.Desktop.Bootstrap; +using SlideGenerator.Desktop.Features.About.Models; +using SlideGenerator.Desktop.Features.About.Services; +using SlideGenerator.Desktop.Services.Localization; + +namespace SlideGenerator.Desktop.Features.About.ViewModels; + +/// +/// Backs the About page (plan §5.7): brand replay, description, update check (moved here from Settings' +/// old closing "Giới thiệu" block), live Developers/Supporters lists, and the repository/sponsor links. +/// Registered as a DI singleton — only runs once per app session, the first time +/// the page is opened (plan: "fetch lazy lần đầu mở"), not on every navigation back to it. +/// +public sealed partial class AboutViewModel : ObservableObject +{ + private readonly IAboutDataService _dataService; + private bool _loaded; + + [ObservableProperty] private bool _isLoadingDevelopers = true; + [ObservableProperty] private bool _isLoadingSupporters = true; + [ObservableProperty] private string? _updateStatusMessage; + [ObservableProperty] private bool _isCheckingForUpdate; + + /// Gets the running app's informational version. + public string Version => Metadata.Value.Version; + + /// Gets the copyright/license line. + public string LicenseText => Metadata.Print.License; + + /// Gets the repository URL, shown as a link. + public string RepositoryUrl => Metadata.Value.Repository; + + /// Gets the English tagline. Shown together with (plan §5.7: "hiển + /// thị cả 2 dòng như idea") — deliberately not run through {loc:Tr}, since both lines always + /// appear together regardless of the active UI language rather than switching with it. + public string DescriptionEn => Metadata.Print.Description; + + /// Gets the Vietnamese tagline — see . + public string DescriptionVi => "Công cụ tự động tạo bài trình chiếu từ mẫu."; + + /// Gets every contributor to the repository — empty until completes. + public ObservableCollection Developers { get; } = []; + + /// Gets every current GitHub Sponsor — empty until completes, or if there + /// are none yet (drives the Supporters empty state, not an error). + public ObservableCollection Supporters { get; } = []; + + /// Gets whether has at least one entry. + public bool HasSupporters => Supporters.Count > 0; + + /// Gets whether has at least one entry. + public bool HasDevelopers => Developers.Count > 0; + + /// Constructs the ViewModel. Data loading is deferred to . + public AboutViewModel(IAboutDataService dataService) + { + _dataService = dataService; + } + + /// Fetches Developers/Supporters once per app session — safe to call every time the page is + /// shown, a no-op after the first successful call. + public async Task LoadAsync() + { + if (_loaded) return; + _loaded = true; + + IsLoadingDevelopers = true; + try + { + foreach (var c in await _dataService.GetContributorsAsync().ConfigureAwait(true)) Developers.Add(c); + OnPropertyChanged(nameof(HasDevelopers)); + } + finally + { + IsLoadingDevelopers = false; + } + + IsLoadingSupporters = true; + try + { + foreach (var s in await _dataService.GetSupportersAsync().ConfigureAwait(true)) Supporters.Add(s); + OnPropertyChanged(nameof(HasSupporters)); + } + finally + { + IsLoadingSupporters = false; + } + } + + [RelayCommand] + private async Task CheckForUpdateAsync() + { + IsCheckingForUpdate = true; + UpdateStatusMessage = null; + try + { + var result = await UpdateChecker.CheckForUpdatesAsync().ConfigureAwait(true); + UpdateStatusMessage = result switch + { + UpdateCheckResult.NotInstalled => LocalizationService.Instance["settings.about.updateStatus.notInstalled"], + UpdateCheckResult.UpToDate => LocalizationService.Instance["settings.about.updateStatus.upToDate"], + UpdateCheckResult.UpdateDownloaded => LocalizationService.Instance["settings.about.updateStatus.downloaded"], + UpdateCheckResult.Failed => LocalizationService.Instance["settings.about.updateStatus.failed"], + _ => null + }; + } + finally + { + IsCheckingForUpdate = false; + } + } + + [RelayCommand] + private static void OpenRepository() + { + OpenUrl(Metadata.Value.Repository); + } + + /// Opens the sponsor page for the one GitHub profile .github/FUNDING.yml currently lists + /// (thnhmai06) — a flyout to choose between multiple profiles is only needed once a second + /// profile is ever added there (plan §5.7). + [RelayCommand] + private static void OpenSponsorPage() + { + OpenUrl("https://github.com/sponsors/thnhmai06"); + } + + private static void OpenUrl(string url) + { + Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); + } +} diff --git a/src/SlideGenerator.Desktop/Features/About/Views/AboutView.axaml b/src/SlideGenerator.Desktop/Features/About/Views/AboutView.axaml new file mode 100644 index 00000000..91cbd732 --- /dev/null +++ b/src/SlideGenerator.Desktop/Features/About/Views/AboutView.axaml @@ -0,0 +1,121 @@ + + + + + + + + + + + + + + + + + + + +