diff --git a/.gitattributes b/.gitattributes
index 7bee716a5d..7a5df63373 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1,26 +1,6 @@
# Auto-detect text files and perform LF normalization
* text=auto
-# Fork-specific files - always keep ours during merge conflicts
-# These files contain customizations that should not be overwritten by upstream merges
-README.md merge=ours
-package_info merge=ours
-bazarr/app/check_update.py merge=ours
-custom_libs/subliminal_patch/providers/opensubtitles_scraper.py merge=ours
-Dockerfile merge=ours
-docker-compose.yml merge=ours
-docker/entrypoint.sh merge=ours
-.dockerignore merge=ours
-.gitattributes merge=ours
-docs/FORK_MAINTENANCE.md merge=ours
-
-# GitHub workflows - keep our fork's versions
-.github/workflows/* merge=ours
-
-# Git submodule - keep our reference
-.gitmodules merge=ours
-opensubtitles-scraper merge=ours
-
# Binary files
*.png binary
*.jpg binary
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
index b663249502..b774a8b9e7 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.md
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -21,7 +21,7 @@ A clear and concise description of what you expected to happen.
If applicable, add screenshots to help explain your problem.
**Software (please complete the following information):**
- - Bazarr: [e.g. v 0.6.1]
+ - Bazarr+: [e.g. v2.0.0]
- Radarr version [e.g. v 0.2.0.0001]
- Sonarr version [e.g. v 2.0.0.0001]
- OS: [e.g. Windows 10]
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
index 6ceaed40d1..cb76eff4d6 100644
--- a/.github/ISSUE_TEMPLATE/config.yml
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -1,11 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: ๐ Wiki
- url: https://github.com/morpheus65535/bazarr/wiki
+ url: https://wiki.bazarr.media
about: The Bazarr wiki should help guide you through installation and setup as well as help resolve common problems and answer frequently asked questions.
- name: ๐ Feature suggestions
- url: https://bazarr.featureupvote.com/
- about: Share your suggestions or ideas to make Bazarr better!
- - name: ๐ Discord Support
- url: https://discord.gg/MH2e2eb
- about: Ask questions and talk about bazarr
+ url: https://github.com/LavX/bazarr/issues
+ about: Share your suggestions or ideas to make Bazarr+ better!
diff --git a/.github/scripts/build_test.sh b/.github/scripts/build_test.sh
index 56ccd6c1a7..d0714b5db0 100755
--- a/.github/scripts/build_test.sh
+++ b/.github/scripts/build_test.sh
@@ -7,7 +7,7 @@ sleep 30
if kill -s 0 $PID
then
- echo "Bazarr is still running. We'll test if UI is working..."
+ echo "Bazarr+ is still running. We'll test if UI is working..."
else
exit 1
fi
diff --git a/.github/workflows/build-docker-manual.yml b/.github/workflows/build-docker-manual.yml
index ea7409caa2..e310afe027 100644
--- a/.github/workflows/build-docker-manual.yml
+++ b/.github/workflows/build-docker-manual.yml
@@ -15,10 +15,6 @@ on:
options:
- master
- development
- - feature/audio-display-with-filter
- - python_3_14
- - ai_translate
- - no_telemetry
default: 'master'
build_type:
@@ -33,7 +29,7 @@ on:
default: 'dev'
version_tag:
- description: 'Version tag (e.g., v1.5.3) - used for release builds'
+ description: 'Version tag (e.g., v2.0.0) - used for release builds'
required: false
default: ''
type: string
@@ -60,82 +56,11 @@ on:
- linux/arm64
default: 'linux/amd64,linux/arm64'
- skip_scraper:
- description: 'Skip building scraper image'
- required: false
- type: boolean
- default: false
-
env:
REGISTRY: ghcr.io
- # Note: IMAGE_NAME and SCRAPER_IMAGE_NAME are set dynamically in jobs to ensure lowercase
UI_DIRECTORY: ./frontend
jobs:
- # ==========================================================================
- # Build Scraper Image (optional)
- # ==========================================================================
- build-scraper:
- if: ${{ !inputs.skip_scraper }}
- runs-on: ubuntu-latest
- permissions:
- contents: read
- packages: write
- steps:
- - name: Set lowercase image names
- run: |
- echo "IMAGE_NAME=${GITHUB_REPOSITORY,,}" >> $GITHUB_ENV
- echo "SCRAPER_IMAGE_NAME=${GITHUB_REPOSITORY_OWNER,,}/opensubtitles-scraper" >> $GITHUB_ENV
-
- - name: Checkout
- uses: actions/checkout@v5
- with:
- ref: ${{ inputs.branch }}
- submodules: recursive
-
- - name: Set up QEMU
- uses: docker/setup-qemu-action@v3
-
- - name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v3
-
- - name: Login to GitHub Container Registry
- uses: docker/login-action@v3
- with:
- registry: ${{ env.REGISTRY }}
- username: ${{ github.actor }}
- password: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Determine Scraper Tags
- id: scraper-tags
- run: |
- TAGS="${{ env.REGISTRY }}/${{ env.SCRAPER_IMAGE_NAME }}:sha-$(git rev-parse --short HEAD)"
-
- case "${{ inputs.build_type }}" in
- release)
- TAGS="${TAGS},${{ env.REGISTRY }}/${{ env.SCRAPER_IMAGE_NAME }}:latest"
- ;;
- nightly)
- TAGS="${TAGS},${{ env.REGISTRY }}/${{ env.SCRAPER_IMAGE_NAME }}:nightly"
- ;;
- dev)
- TAGS="${TAGS},${{ env.REGISTRY }}/${{ env.SCRAPER_IMAGE_NAME }}:dev"
- ;;
- esac
-
- echo "tags=$TAGS" >> $GITHUB_OUTPUT
-
- - name: Build and Push Scraper Image
- uses: docker/build-push-action@v6
- with:
- context: ./opensubtitles-scraper
- file: ./opensubtitles-scraper/Dockerfile
- platforms: ${{ inputs.platforms }}
- push: true
- tags: ${{ steps.scraper-tags.outputs.tags }}
- cache-from: type=gha,scope=scraper
- cache-to: type=gha,mode=max,scope=scraper
-
# ==========================================================================
# Build Frontend
# ==========================================================================
@@ -148,7 +73,7 @@ jobs:
ref: ${{ inputs.branch }}
- name: Setup NodeJS
- uses: actions/setup-node@v4
+ uses: actions/setup-node@v6
with:
node-version-file: "${{ env.UI_DIRECTORY }}/.nvmrc"
cache: 'npm'
@@ -185,7 +110,6 @@ jobs:
- name: Set lowercase image names
run: |
echo "IMAGE_NAME=${GITHUB_REPOSITORY,,}" >> $GITHUB_ENV
- echo "SCRAPER_IMAGE_NAME=${GITHUB_REPOSITORY_OWNER,,}/opensubtitles-scraper" >> $GITHUB_ENV
- name: Checkout
uses: actions/checkout@v5
@@ -233,7 +157,7 @@ jobs:
VERSION=$(git describe --tags --always 2>/dev/null || echo "0.0.0")
fi
VERSION="${VERSION#v}" # Remove 'v' prefix
- FORK_VERSION="${VERSION}-lavx"
+ FORK_VERSION="${VERSION}"
TAGS="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${FORK_VERSION}"
TAGS="${TAGS},${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:v${VERSION}"
@@ -277,11 +201,16 @@ jobs:
if [ -z "$CUSTOM_TAG" ]; then
CUSTOM_TAG="custom-${SHORT_SHA}"
fi
- FORK_VERSION="${CUSTOM_TAG}"
-
+ # Use version_tag for display if provided, otherwise custom tag
+ if [ -n "${{ inputs.version_tag }}" ]; then
+ FORK_VERSION="${{ inputs.version_tag }}"
+ else
+ FORK_VERSION="${CUSTOM_TAG}"
+ fi
+
TAGS="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${CUSTOM_TAG}"
TAGS="${TAGS},${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${SHORT_SHA}"
-
+
echo "display_version=${FORK_VERSION}" >> $GITHUB_OUTPUT
;;
esac
@@ -322,8 +251,8 @@ jobs:
push: true
tags: ${{ steps.version.outputs.tags }}
labels: |
- org.opencontainers.image.title=Bazarr (LavX Fork)
- org.opencontainers.image.description=Bazarr with OpenSubtitles.org scraper support
+ org.opencontainers.image.title=Bazarr+
+ org.opencontainers.image.description=Bazarr+ - enhanced subtitle management
org.opencontainers.image.vendor=LavX
org.opencontainers.image.version=${{ steps.version.outputs.fork_version }}
org.opencontainers.image.revision=${{ github.sha }}
diff --git a/.github/workflows/build-docker.yml b/.github/workflows/build-docker.yml
index 9f6047b681..4522e7f11a 100644
--- a/.github/workflows/build-docker.yml
+++ b/.github/workflows/build-docker.yml
@@ -3,7 +3,6 @@ name: Build Docker Image
on:
push:
branches:
- - main
- master
paths-ignore:
- '*.md'
@@ -12,82 +11,16 @@ on:
workflow_dispatch:
inputs:
version_tag:
- description: 'Version tag for the image (e.g., v1.5.3)'
- required: false
- default: ''
- type: string
- workflow_call:
- inputs:
- version_tag:
- description: 'Version tag for the image'
+ description: 'Version tag for the image (e.g., v2.0.0)'
required: false
default: ''
type: string
env:
REGISTRY: ghcr.io
- # Note: IMAGE_NAME and SCRAPER_IMAGE_NAME are set dynamically in jobs to ensure lowercase
UI_DIRECTORY: ./frontend
jobs:
- # ==========================================================================
- # Build Scraper Image (separate job with its own cache)
- # ==========================================================================
- build-scraper:
- runs-on: ubuntu-latest
- permissions:
- contents: read
- packages: write
- steps:
- - name: Set lowercase image names
- run: |
- echo "IMAGE_NAME=${GITHUB_REPOSITORY,,}" >> $GITHUB_ENV
- echo "SCRAPER_IMAGE_NAME=${GITHUB_REPOSITORY_OWNER,,}/opensubtitles-scraper" >> $GITHUB_ENV
-
- - name: Checkout
- uses: actions/checkout@v5
- with:
- submodules: recursive
-
- - name: Set up QEMU
- uses: docker/setup-qemu-action@v3
-
- - name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v3
-
- - name: Login to GitHub Container Registry
- uses: docker/login-action@v3
- with:
- registry: ${{ env.REGISTRY }}
- username: ${{ github.actor }}
- password: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Extract Scraper Metadata
- id: meta
- uses: docker/metadata-action@v5
- with:
- images: ${{ env.REGISTRY }}/${{ env.SCRAPER_IMAGE_NAME }}
- tags: |
- type=raw,value=latest,enable={{is_default_branch}}
- type=sha,prefix=sha-
- type=ref,event=branch
- labels: |
- org.opencontainers.image.title=OpenSubtitles.org Scraper
- org.opencontainers.image.description=Web scraper service for OpenSubtitles.org
- org.opencontainers.image.vendor=LavX
-
- - name: Build and Push Scraper Image
- uses: docker/build-push-action@v6
- with:
- context: ./opensubtitles-scraper
- file: ./opensubtitles-scraper/Dockerfile
- platforms: linux/amd64,linux/arm64
- push: true
- tags: ${{ steps.meta.outputs.tags }}
- labels: ${{ steps.meta.outputs.labels }}
- cache-from: type=gha,scope=scraper
- cache-to: type=gha,mode=max,scope=scraper
-
# ==========================================================================
# Build Frontend (cached via GitHub Actions cache)
# ==========================================================================
@@ -98,7 +31,7 @@ jobs:
uses: actions/checkout@v5
- name: Setup NodeJS
- uses: actions/setup-node@v4
+ uses: actions/setup-node@v6
with:
node-version-file: "${{ env.UI_DIRECTORY }}/.nvmrc"
cache: 'npm'
@@ -123,7 +56,7 @@ jobs:
# Build and Push Bazarr Image
# ==========================================================================
build-and-push:
- needs: [build-frontend, build-scraper]
+ needs: [build-frontend]
runs-on: ubuntu-latest
permissions:
contents: read
@@ -132,10 +65,9 @@ jobs:
id-token: write
steps:
- - name: Set lowercase image names
+ - name: Set lowercase image name
run: |
echo "IMAGE_NAME=${GITHUB_REPOSITORY,,}" >> $GITHUB_ENV
- echo "SCRAPER_IMAGE_NAME=${GITHUB_REPOSITORY_OWNER,,}/opensubtitles-scraper" >> $GITHUB_ENV
- name: Checkout
uses: actions/checkout@v5
@@ -176,19 +108,17 @@ jobs:
# Remove 'v' prefix for semver
SEMVER="${VERSION#v}"
- # Create LavX fork version
+ # Bazarr+ version is the semver tag directly
SHORT_SHA=$(git rev-parse --short HEAD)
- BUILD_DATE=$(date -u +%Y%m%d)
- FORK_VERSION="${SEMVER}-lavx.${BUILD_DATE}"
-
+ FORK_VERSION="${SEMVER}"
+
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "semver=$SEMVER" >> $GITHUB_OUTPUT
echo "fork_version=$FORK_VERSION" >> $GITHUB_OUTPUT
echo "short_sha=$SHORT_SHA" >> $GITHUB_OUTPUT
-
+
echo "## Version Info" >> $GITHUB_STEP_SUMMARY
- echo "- **Base Version:** $VERSION" >> $GITHUB_STEP_SUMMARY
- echo "- **Fork Version:** $FORK_VERSION" >> $GITHUB_STEP_SUMMARY
+ echo "- **Version:** $FORK_VERSION" >> $GITHUB_STEP_SUMMARY
echo "- **Short SHA:** $SHORT_SHA" >> $GITHUB_STEP_SUMMARY
- name: Extract Docker Metadata
@@ -199,15 +129,15 @@ jobs:
tags: |
# Latest tag on main/master branch
type=raw,value=latest,enable={{is_default_branch}}
- # Version tag (e.g., v1.5.3-lavx.20241214)
+ # Version tag (e.g., v2.0.0)
type=raw,value=${{ steps.version.outputs.fork_version }}
# Short SHA tag
type=raw,value=sha-${{ steps.version.outputs.short_sha }}
# Branch name
type=ref,event=branch
labels: |
- org.opencontainers.image.title=Bazarr (LavX Fork)
- org.opencontainers.image.description=Bazarr with OpenSubtitles.org scraper support
+ org.opencontainers.image.title=Bazarr+
+ org.opencontainers.image.description=Bazarr+ - enhanced subtitle management
org.opencontainers.image.vendor=LavX
org.opencontainers.image.version=${{ steps.version.outputs.fork_version }}
@@ -242,17 +172,12 @@ jobs:
run: |
echo "## ๐ณ Docker Images Published" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
- echo "### Bazarr (LavX Fork)" >> $GITHUB_STEP_SUMMARY
+ echo "### Bazarr+" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`bash" >> $GITHUB_STEP_SUMMARY
echo "docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest" >> $GITHUB_STEP_SUMMARY
echo "docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.fork_version }}" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
- echo "### OpenSubtitles Scraper" >> $GITHUB_STEP_SUMMARY
- echo "\`\`\`bash" >> $GITHUB_STEP_SUMMARY
- echo "docker pull ${{ env.REGISTRY }}/${{ env.SCRAPER_IMAGE_NAME }}:latest" >> $GITHUB_STEP_SUMMARY
- echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
- echo "" >> $GITHUB_STEP_SUMMARY
echo "### Image Details" >> $GITHUB_STEP_SUMMARY
echo "- **Digest:** \`${{ steps.push.outputs.digest }}\`" >> $GITHUB_STEP_SUMMARY
echo "- **Platforms:** linux/amd64, linux/arm64" >> $GITHUB_STEP_SUMMARY
@@ -279,17 +204,12 @@ jobs:
## ๐ณ Docker Images
```bash
- # Bazarr (LavX Fork)
docker pull ghcr.io/${{ github.repository }}:${{ github.ref_name }}
-
- # OpenSubtitles Scraper
- docker pull ghcr.io/${{ github.repository_owner }}/opensubtitles-scraper:latest
```
## ๐ Changes
This release is based on upstream Bazarr with the following custom modifications:
- OpenSubtitles.org web scraper provider (no VIP API needed)
- - Auto-sync with upstream daily at 4 AM UTC
See the auto-generated release notes below for detailed changes.
\ No newline at end of file
diff --git a/.github/workflows/build-scraper.yml b/.github/workflows/build-scraper.yml
deleted file mode 100644
index c0556a4b34..0000000000
--- a/.github/workflows/build-scraper.yml
+++ /dev/null
@@ -1,276 +0,0 @@
-name: Build OpenSubtitles Scraper
-
-# =============================================================================
-# Standalone workflow for building the OpenSubtitles Scraper Docker image
-# Can be triggered manually with version and platform options
-# =============================================================================
-
-on:
- workflow_dispatch:
- inputs:
- build_type:
- description: 'Build type'
- required: true
- type: choice
- options:
- - release # latest + version tag
- - dev # dev + sha tag
- - nightly # nightly + date tag
- default: 'dev'
-
- version_tag:
- description: 'Version tag (e.g., v1.0.0) - used for release builds'
- required: false
- default: ''
- type: string
-
- python_version:
- description: 'Python version for the image'
- required: true
- type: choice
- options:
- - '3.14'
- - '3.13'
- - '3.12'
- default: '3.14'
-
- enable_jit:
- description: 'Enable Python JIT compiler'
- required: false
- type: boolean
- default: true
-
- platforms:
- description: 'Target platforms'
- required: true
- type: choice
- options:
- - linux/amd64,linux/arm64
- - linux/amd64
- - linux/arm64
- default: 'linux/amd64,linux/arm64'
-
- push_latest:
- description: 'Also tag as latest'
- required: false
- type: boolean
- default: false
-
-env:
- REGISTRY: ghcr.io
- # Note: IMAGE_NAME is set dynamically in job to ensure lowercase
-
-jobs:
- build-scraper:
- runs-on: ubuntu-latest
- permissions:
- contents: read
- packages: write
- attestations: write
- id-token: write
-
- steps:
- - name: Set lowercase image name
- run: |
- echo "IMAGE_NAME=${GITHUB_REPOSITORY_OWNER,,}/opensubtitles-scraper" >> $GITHUB_ENV
-
- - name: Checkout
- uses: actions/checkout@v5
- with:
- submodules: recursive
- fetch-depth: 0
-
- - name: Set up QEMU
- uses: docker/setup-qemu-action@v3
-
- - name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v3
-
- - name: Login to GitHub Container Registry
- uses: docker/login-action@v3
- with:
- registry: ${{ env.REGISTRY }}
- username: ${{ github.actor }}
- password: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Determine Version and Tags
- id: version
- working-directory: ./opensubtitles-scraper
- run: |
- SHORT_SHA=$(git rev-parse --short HEAD)
- BUILD_DATE=$(date -u +%Y%m%d)
- PYTHON_VER="${{ inputs.python_version }}"
- JIT_SUFFIX="${{ inputs.enable_jit == true && '-jit' || '' }}"
-
- # Initialize tags
- TAGS=""
- VERSION=""
-
- case "${{ inputs.build_type }}" in
- release)
- if [ -n "${{ inputs.version_tag }}" ]; then
- VERSION="${{ inputs.version_tag }}"
- else
- VERSION=$(git describe --tags --always 2>/dev/null || echo "1.0.0")
- fi
- VERSION="${VERSION#v}"
- DISPLAY_VERSION="v${VERSION}-py${PYTHON_VER}${JIT_SUFFIX}"
-
- TAGS="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${VERSION}"
- TAGS="${TAGS},${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:v${VERSION}"
- TAGS="${TAGS},${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:py${PYTHON_VER}${JIT_SUFFIX}"
- ;;
-
- nightly)
- VERSION="nightly-${BUILD_DATE}"
- DISPLAY_VERSION="${VERSION}-py${PYTHON_VER}${JIT_SUFFIX}"
-
- TAGS="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:nightly"
- TAGS="${TAGS},${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:nightly-${BUILD_DATE}"
- TAGS="${TAGS},${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:nightly-py${PYTHON_VER}${JIT_SUFFIX}"
- ;;
-
- dev)
- VERSION="dev-${SHORT_SHA}"
- DISPLAY_VERSION="${VERSION}-py${PYTHON_VER}${JIT_SUFFIX}"
-
- TAGS="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:dev"
- TAGS="${TAGS},${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:dev-${SHORT_SHA}"
- TAGS="${TAGS},${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:dev-py${PYTHON_VER}${JIT_SUFFIX}"
- ;;
- esac
-
- # Always add sha tag for immutability
- TAGS="${TAGS},${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${SHORT_SHA}"
-
- # Add latest if requested
- if [ "${{ inputs.push_latest }}" == "true" ]; then
- TAGS="${TAGS},${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest"
- fi
-
- echo "tags=${TAGS}" >> $GITHUB_OUTPUT
- echo "version=${VERSION}" >> $GITHUB_OUTPUT
- echo "display_version=${DISPLAY_VERSION}" >> $GITHUB_OUTPUT
- echo "short_sha=${SHORT_SHA}" >> $GITHUB_OUTPUT
- echo "python_version=${PYTHON_VER}" >> $GITHUB_OUTPUT
- echo "jit_enabled=${{ inputs.enable_jit }}" >> $GITHUB_OUTPUT
-
- # Build summary
- echo "## ๐ท๏ธ Scraper Build Info" >> $GITHUB_STEP_SUMMARY
- echo "" >> $GITHUB_STEP_SUMMARY
- echo "| Property | Value |" >> $GITHUB_STEP_SUMMARY
- echo "|----------|-------|" >> $GITHUB_STEP_SUMMARY
- echo "| **Build Type** | ${{ inputs.build_type }} |" >> $GITHUB_STEP_SUMMARY
- echo "| **Version** | ${DISPLAY_VERSION} |" >> $GITHUB_STEP_SUMMARY
- echo "| **Python** | ${PYTHON_VER} |" >> $GITHUB_STEP_SUMMARY
- echo "| **JIT** | ${{ inputs.enable_jit }} |" >> $GITHUB_STEP_SUMMARY
- echo "| **Platforms** | ${{ inputs.platforms }} |" >> $GITHUB_STEP_SUMMARY
- echo "| **SHA** | ${SHORT_SHA} |" >> $GITHUB_STEP_SUMMARY
- echo "" >> $GITHUB_STEP_SUMMARY
- echo "### Tags" >> $GITHUB_STEP_SUMMARY
- echo '```' >> $GITHUB_STEP_SUMMARY
- echo "${TAGS}" | tr ',' '\n' >> $GITHUB_STEP_SUMMARY
- echo '```' >> $GITHUB_STEP_SUMMARY
-
- - name: Generate Dockerfile with Python version
- run: |
- cd opensubtitles-scraper
-
- # Create a modified Dockerfile with the selected Python version
- cat > Dockerfile.build << EOF
- # Generated Dockerfile with Python ${{ steps.version.outputs.python_version }}
- FROM python:${{ steps.version.outputs.python_version }}-slim
-
- # Set working directory
- WORKDIR /app
-
- # Install system dependencies
- RUN apt-get update && apt-get install -y \\
- gcc \\
- && rm -rf /var/lib/apt/lists/*
-
- # Set environment variables
- ENV PYTHONDONTWRITEBYTECODE=1 \\
- PYTHONUNBUFFERED=1
- EOF
-
- # Add JIT env var if enabled
- if [ "${{ inputs.enable_jit }}" == "true" ]; then
- echo 'ENV PYTHON_JIT=1' >> Dockerfile.build
- fi
-
- cat >> Dockerfile.build << 'EOF'
-
- # Copy requirements file
- COPY requirements.txt .
-
- # Install Python dependencies
- RUN pip install --no-cache-dir --upgrade pip && \
- pip install --no-cache-dir -r requirements.txt
-
- # Copy application code
- COPY . .
-
- # Expose port 8000
- EXPOSE 8000
-
- # Health check
- HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
- CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1
-
- # Run the application
- CMD ["python", "main.py"]
- EOF
-
- cat Dockerfile.build
-
- - name: Build and Push Scraper Image
- id: push
- uses: docker/build-push-action@v6
- with:
- context: ./opensubtitles-scraper
- file: ./opensubtitles-scraper/Dockerfile.build
- platforms: ${{ inputs.platforms }}
- push: true
- tags: ${{ steps.version.outputs.tags }}
- labels: |
- org.opencontainers.image.title=OpenSubtitles.org Scraper
- org.opencontainers.image.description=Web scraper service for OpenSubtitles.org (LavX Fork)
- org.opencontainers.image.vendor=LavX
- org.opencontainers.image.version=${{ steps.version.outputs.display_version }}
- org.opencontainers.image.revision=${{ github.sha }}
- org.opencontainers.image.source=https://github.com/${{ github.repository }}
- org.opencontainers.image.python-version=${{ steps.version.outputs.python_version }}
- org.opencontainers.image.jit-enabled=${{ inputs.enable_jit }}
- cache-from: type=gha,scope=scraper-${{ inputs.python_version }}
- cache-to: type=gha,mode=max,scope=scraper-${{ inputs.python_version }}
-
- - name: Generate Artifact Attestation
- uses: actions/attest-build-provenance@v2
- with:
- subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
- subject-digest: ${{ steps.push.outputs.digest }}
- push-to-registry: true
-
- - name: Create Build Summary
- run: |
- echo "" >> $GITHUB_STEP_SUMMARY
- echo "## ๐ณ Scraper Image Published" >> $GITHUB_STEP_SUMMARY
- echo "" >> $GITHUB_STEP_SUMMARY
- echo "### Pull Commands" >> $GITHUB_STEP_SUMMARY
- echo '```bash' >> $GITHUB_STEP_SUMMARY
- echo "# By version" >> $GITHUB_STEP_SUMMARY
- echo "docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}" >> $GITHUB_STEP_SUMMARY
- echo "" >> $GITHUB_STEP_SUMMARY
- echo "# By SHA (immutable)" >> $GITHUB_STEP_SUMMARY
- echo "docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ steps.version.outputs.short_sha }}" >> $GITHUB_STEP_SUMMARY
- echo "" >> $GITHUB_STEP_SUMMARY
- echo "# With Python version tag" >> $GITHUB_STEP_SUMMARY
- echo "docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ inputs.build_type }}-py${{ inputs.python_version }}${{ inputs.enable_jit == true && '-jit' || '' }}" >> $GITHUB_STEP_SUMMARY
- echo '```' >> $GITHUB_STEP_SUMMARY
- echo "" >> $GITHUB_STEP_SUMMARY
- echo "### Image Details" >> $GITHUB_STEP_SUMMARY
- echo "- **Digest:** \`${{ steps.push.outputs.digest }}\`" >> $GITHUB_STEP_SUMMARY
- echo "- **Platforms:** ${{ inputs.platforms }}" >> $GITHUB_STEP_SUMMARY
- echo "- **Python Version:** ${{ inputs.python_version }}" >> $GITHUB_STEP_SUMMARY
- echo "- **JIT Enabled:** ${{ inputs.enable_jit }}" >> $GITHUB_STEP_SUMMARY
\ No newline at end of file
diff --git a/.github/workflows/build-translator.yml b/.github/workflows/build-translator.yml
deleted file mode 100644
index 1d1fa34fab..0000000000
--- a/.github/workflows/build-translator.yml
+++ /dev/null
@@ -1,306 +0,0 @@
-name: Build AI Subtitle Translator
-
-# =============================================================================
-# Standalone workflow for building the AI Subtitle Translator Docker image
-# Can be triggered manually with version and platform options
-# =============================================================================
-
-on:
- workflow_dispatch:
- inputs:
- source_branch:
- description: 'Branch containing the ai-subtitle-translator submodule'
- required: true
- type: choice
- options:
- - ai_translate
- - development
- - master
- default: 'ai_translate'
-
- build_type:
- description: 'Build type'
- required: true
- type: choice
- options:
- - release # latest + version tag
- - dev # dev + sha tag
- - nightly # nightly + date tag
- default: 'dev'
-
- version_tag:
- description: 'Version tag (e.g., v1.0.0) - used for release builds'
- required: false
- default: ''
- type: string
-
- python_version:
- description: 'Python version for the image'
- required: true
- type: choice
- options:
- - '3.14'
- - '3.13'
- - '3.12'
- default: '3.14'
-
- enable_jit:
- description: 'Enable Python JIT compiler'
- required: false
- type: boolean
- default: true
-
- platforms:
- description: 'Target platforms'
- required: true
- type: choice
- options:
- - linux/amd64,linux/arm64
- - linux/amd64
- - linux/arm64
- default: 'linux/amd64,linux/arm64'
-
- push_latest:
- description: 'Also tag as latest'
- required: false
- type: boolean
- default: false
-
-env:
- REGISTRY: ghcr.io
- # Force lowercase for Docker image names
- IMAGE_NAME: lavx/ai-subtitle-translator
-
-jobs:
- build-translator:
- runs-on: ubuntu-latest
- permissions:
- contents: read
- packages: write
- attestations: write
- id-token: write
-
- steps:
- - name: Checkout
- uses: actions/checkout@v5
- with:
- ref: ${{ inputs.source_branch }}
- submodules: recursive
- fetch-depth: 0
-
- - name: Verify submodule exists
- run: |
- if [ ! -d "ai-subtitle-translator" ]; then
- echo "โ Error: ai-subtitle-translator submodule not found!"
- echo "The selected branch '${{ inputs.source_branch }}' may not have the submodule configured."
- echo ""
- echo "Available branches with the submodule:"
- echo " - ai_translate (recommended)"
- exit 1
- fi
- echo "โ
Submodule found: ai-subtitle-translator"
- ls -la ai-subtitle-translator/
-
- - name: Set up QEMU
- uses: docker/setup-qemu-action@v3
-
- - name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v3
-
- - name: Login to GitHub Container Registry
- uses: docker/login-action@v3
- with:
- registry: ${{ env.REGISTRY }}
- username: ${{ github.actor }}
- password: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Determine Version and Tags
- id: version
- working-directory: ./ai-subtitle-translator
- run: |
- SHORT_SHA=$(git rev-parse --short HEAD)
- BUILD_DATE=$(date -u +%Y%m%d)
- PYTHON_VER="${{ inputs.python_version }}"
- JIT_SUFFIX="${{ inputs.enable_jit == true && '-jit' || '' }}"
-
- # Initialize tags
- TAGS=""
- VERSION=""
-
- case "${{ inputs.build_type }}" in
- release)
- if [ -n "${{ inputs.version_tag }}" ]; then
- VERSION="${{ inputs.version_tag }}"
- else
- VERSION=$(git describe --tags --always 2>/dev/null || echo "1.0.0")
- fi
- VERSION="${VERSION#v}"
- DISPLAY_VERSION="v${VERSION}-py${PYTHON_VER}${JIT_SUFFIX}"
-
- TAGS="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${VERSION}"
- TAGS="${TAGS},${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:v${VERSION}"
- TAGS="${TAGS},${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:py${PYTHON_VER}${JIT_SUFFIX}"
- ;;
-
- nightly)
- VERSION="nightly-${BUILD_DATE}"
- DISPLAY_VERSION="${VERSION}-py${PYTHON_VER}${JIT_SUFFIX}"
-
- TAGS="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:nightly"
- TAGS="${TAGS},${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:nightly-${BUILD_DATE}"
- TAGS="${TAGS},${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:nightly-py${PYTHON_VER}${JIT_SUFFIX}"
- ;;
-
- dev)
- VERSION="dev-${SHORT_SHA}"
- DISPLAY_VERSION="${VERSION}-py${PYTHON_VER}${JIT_SUFFIX}"
-
- TAGS="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:dev"
- TAGS="${TAGS},${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:dev-${SHORT_SHA}"
- TAGS="${TAGS},${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:dev-py${PYTHON_VER}${JIT_SUFFIX}"
- ;;
- esac
-
- # Always add sha tag for immutability
- TAGS="${TAGS},${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${SHORT_SHA}"
-
- # Add latest if requested
- if [ "${{ inputs.push_latest }}" == "true" ]; then
- TAGS="${TAGS},${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest"
- fi
-
- echo "tags=${TAGS}" >> $GITHUB_OUTPUT
- echo "version=${VERSION}" >> $GITHUB_OUTPUT
- echo "display_version=${DISPLAY_VERSION}" >> $GITHUB_OUTPUT
- echo "short_sha=${SHORT_SHA}" >> $GITHUB_OUTPUT
- echo "python_version=${PYTHON_VER}" >> $GITHUB_OUTPUT
- echo "jit_enabled=${{ inputs.enable_jit }}" >> $GITHUB_OUTPUT
-
- # Build summary
- echo "## ๐ท๏ธ AI Subtitle Translator Build Info" >> $GITHUB_STEP_SUMMARY
- echo "" >> $GITHUB_STEP_SUMMARY
- echo "| Property | Value |" >> $GITHUB_STEP_SUMMARY
- echo "|----------|-------|" >> $GITHUB_STEP_SUMMARY
- echo "| **Build Type** | ${{ inputs.build_type }} |" >> $GITHUB_STEP_SUMMARY
- echo "| **Version** | ${DISPLAY_VERSION} |" >> $GITHUB_STEP_SUMMARY
- echo "| **Python** | ${PYTHON_VER} |" >> $GITHUB_STEP_SUMMARY
- echo "| **JIT** | ${{ inputs.enable_jit }} |" >> $GITHUB_STEP_SUMMARY
- echo "| **Platforms** | ${{ inputs.platforms }} |" >> $GITHUB_STEP_SUMMARY
- echo "| **SHA** | ${SHORT_SHA} |" >> $GITHUB_STEP_SUMMARY
- echo "" >> $GITHUB_STEP_SUMMARY
- echo "### Tags" >> $GITHUB_STEP_SUMMARY
- echo '```' >> $GITHUB_STEP_SUMMARY
- echo "${TAGS}" | tr ',' '\n' >> $GITHUB_STEP_SUMMARY
- echo '```' >> $GITHUB_STEP_SUMMARY
-
- - name: Generate Dockerfile with Python version
- run: |
- cd ai-subtitle-translator
-
- # Create a modified Dockerfile with the selected Python version
- cat > Dockerfile.build << EOF
- # Generated Dockerfile with Python ${{ steps.version.outputs.python_version }}
- FROM python:${{ steps.version.outputs.python_version }}-slim
-
- # Set working directory
- WORKDIR /app
-
- # Install system dependencies
- RUN apt-get update && apt-get install -y --no-install-recommends \\
- gcc curl \\
- && rm -rf /var/lib/apt/lists/*
-
- # Set environment variables
- ENV PYTHONDONTWRITEBYTECODE=1 \\
- PYTHONUNBUFFERED=1
- EOF
-
- # Add JIT env var if enabled
- if [ "${{ inputs.enable_jit }}" == "true" ]; then
- echo 'ENV PYTHON_JIT=1' >> Dockerfile.build
- fi
-
- cat >> Dockerfile.build << 'EOF'
-
- # Copy requirements first for better caching
- COPY requirements.txt .
-
- # Install Python dependencies
- RUN pip install --no-cache-dir --upgrade pip && \
- pip install --no-cache-dir -r requirements.txt
-
- # Copy source code and project files
- COPY pyproject.toml .
- COPY src/ ./src/
-
- # Install the package itself (makes subtitle_translator importable)
- RUN pip install --no-cache-dir -e .
-
- # Create non-root user for security
- RUN useradd --create-home --shell /bin/bash appuser && \
- chown -R appuser:appuser /app
- USER appuser
-
- # Expose port 8765
- EXPOSE 8765
-
- # Health check
- HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
- CMD curl -f http://localhost:8765/health || exit 1
-
- # Run the application
- CMD ["uvicorn", "subtitle_translator.main:app", "--host", "0.0.0.0", "--port", "8765"]
- EOF
-
- cat Dockerfile.build
-
- - name: Build and Push Translator Image
- id: push
- uses: docker/build-push-action@v6
- with:
- context: ./ai-subtitle-translator
- file: ./ai-subtitle-translator/Dockerfile.build
- platforms: ${{ inputs.platforms }}
- push: true
- tags: ${{ steps.version.outputs.tags }}
- labels: |
- org.opencontainers.image.title=AI Subtitle Translator
- org.opencontainers.image.description=LLM-powered subtitle translation service using OpenRouter API
- org.opencontainers.image.vendor=LavX
- org.opencontainers.image.version=${{ steps.version.outputs.display_version }}
- org.opencontainers.image.revision=${{ github.sha }}
- org.opencontainers.image.source=https://github.com/${{ github.repository }}
- org.opencontainers.image.python-version=${{ steps.version.outputs.python_version }}
- org.opencontainers.image.jit-enabled=${{ inputs.enable_jit }}
- cache-from: type=gha,scope=translator-${{ inputs.python_version }}
- cache-to: type=gha,mode=max,scope=translator-${{ inputs.python_version }}
-
- - name: Generate Artifact Attestation
- uses: actions/attest-build-provenance@v2
- with:
- subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
- subject-digest: ${{ steps.push.outputs.digest }}
- push-to-registry: true
-
- - name: Create Build Summary
- run: |
- echo "" >> $GITHUB_STEP_SUMMARY
- echo "## ๐ณ AI Subtitle Translator Image Published" >> $GITHUB_STEP_SUMMARY
- echo "" >> $GITHUB_STEP_SUMMARY
- echo "### Pull Commands" >> $GITHUB_STEP_SUMMARY
- echo '```bash' >> $GITHUB_STEP_SUMMARY
- echo "# By version" >> $GITHUB_STEP_SUMMARY
- echo "docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}" >> $GITHUB_STEP_SUMMARY
- echo "" >> $GITHUB_STEP_SUMMARY
- echo "# By SHA (immutable)" >> $GITHUB_STEP_SUMMARY
- echo "docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ steps.version.outputs.short_sha }}" >> $GITHUB_STEP_SUMMARY
- echo "" >> $GITHUB_STEP_SUMMARY
- echo "# With Python version tag" >> $GITHUB_STEP_SUMMARY
- echo "docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ inputs.build_type }}-py${{ inputs.python_version }}${{ inputs.enable_jit == true && '-jit' || '' }}" >> $GITHUB_STEP_SUMMARY
- echo '```' >> $GITHUB_STEP_SUMMARY
- echo "" >> $GITHUB_STEP_SUMMARY
- echo "### Image Details" >> $GITHUB_STEP_SUMMARY
- echo "- **Digest:** \`${{ steps.push.outputs.digest }}\`" >> $GITHUB_STEP_SUMMARY
- echo "- **Platforms:** ${{ inputs.platforms }}" >> $GITHUB_STEP_SUMMARY
- echo "- **Python Version:** ${{ inputs.python_version }}" >> $GITHUB_STEP_SUMMARY
- echo "- **JIT Enabled:** ${{ inputs.enable_jit }}" >> $GITHUB_STEP_SUMMARY
\ No newline at end of file
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 0848a9e187..6699519db9 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -2,7 +2,7 @@ name: CI
on:
push:
- branches: [main]
+ branches: [master, development]
paths:
- frontend/**
- bazarr/**
@@ -14,7 +14,7 @@ on:
- dev-requirements.txt
- .github/workflows/ci.yml
pull_request:
- branches: [main]
+ branches: [master, development]
env:
ROOT_DIRECTORY: .
@@ -77,7 +77,7 @@ jobs:
strategy:
fail-fast: false
matrix:
- python-version: [ '3.8', '3.9', '3.10', '3.11', '3.12', '3.13', '3.14' ]
+ python-version: [ '3.12', '3.13', '3.14' ]
name: Python ${{ matrix.python-version }} backend
steps:
@@ -90,7 +90,7 @@ jobs:
python-version: ${{ matrix.python-version }}
- name: Install UI
- uses: actions/download-artifact@v5
+ uses: actions/download-artifact@v4
with:
name: ${{ env.UI_ARTIFACT_NAME }}
path: "${{ env.UI_DIRECTORY }}/build"
diff --git a/.github/workflows/test_bazarr_execution.yml b/.github/workflows/test_bazarr_execution.yml
index cca016553e..0272c6ebf5 100644
--- a/.github/workflows/test_bazarr_execution.yml
+++ b/.github/workflows/test_bazarr_execution.yml
@@ -13,7 +13,7 @@ jobs:
strategy:
fail-fast: false
matrix:
- python-version: [ '3.8', '3.9', '3.10', '3.11', '3.12', '3.13', '3.14' ]
+ python-version: [ '3.12', '3.13', '3.14' ]
name: Python ${{ matrix.python-version }} test
steps:
diff --git a/.gitignore b/.gitignore
index d70e8a9450..16e1ffd382 100644
--- a/.gitignore
+++ b/.gitignore
@@ -24,3 +24,7 @@ VERSION
# Allow
!*.dll
+.claude/
+.superpowers/
+docs/superpowers/
+.coverage
diff --git a/.gitmodules b/.gitmodules
index 8bfc782734..08ab19ed6e 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -4,3 +4,6 @@
[submodule "ai-subtitle-translator"]
path = ai-subtitle-translator
url = https://github.com/LavX/ai-subtitle-translator.git
+[submodule "bazarr-binaries"]
+ path = bazarr-binaries
+ url = https://github.com/LavX/bazarr-binaries.git
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index ccf3419a4d..c9d3609e26 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,45 +1,120 @@
-# How to Contribute
+# Contributing to Bazarr+
## Tools required
-- Python 3.8.x to 3.12.x (3.10.x is highly recommended and 3.13 or greater is proscribed).
-- Pycharm or Visual Studio code IDE are recommended but if you're happy with VIM, enjoy it!
-- Git.
-- UI testing must be done using Chrome latest version.
+- Python 3.12+ (3.14 recommended, matches Docker image)
+- Node.js (version in `frontend/.nvmrc`)
+- Git
+- Docker and Docker Compose (for integration testing)
+- UI testing should be done in Chrome latest version
-## Warning
+## Branching
-As we're using Git in the development process, you better disable automatic update of Bazarr in UI or you may get your changes overwritten. Alternatively, you can completely disable the update module by running Bazarr with `--no-update` command line argument.
+### Branch model
-## Branching
+- `master` contains stable releases, tagged with semver versions (e.g., `v2.0.0`, `v2.1.0`)
+- `development` is the integration branch where upstream merges and new features land
+- Feature branches are created from `development` and merged back via PR
+
+### Rules
+
+- `master` is not merged back to `development`
+- All feature branches are branched from `development`
+- Cherry-picked upstream fixes go into `development` first, never directly to `master`
+
+## Upstream relationship
+
+Bazarr+ is a hard fork of [upstream Bazarr](https://github.com/morpheus65535/bazarr). There is no automatic synchronization. Bug fixes from upstream may be cherry-picked selectively when relevant, but upstream releases are not merged wholesale.
+
+## Contribution workflow
+
+1. Fork the repository
+2. Create a feature branch from `development`
+3. Make your changes
+4. Write or update tests for your changes
+5. Run linting and tests, verify they pass
+6. Submit a PR targeting the `development` branch
+
+For major changes, open an issue first to discuss the approach.
+
+## Linting
+
+All frontend code must pass ESLint before submitting a PR.
+
+```bash
+cd frontend
+
+# Check for lint errors
+npm run check
+
+# Auto-fix import sorting and formatting
+npx eslint --fix --ext .ts,.tsx src/
+```
+
+Fix all errors before submitting. Warnings should be addressed when practical.
+
+## Testing
+
+PRs should include tests when the change is testable. We use:
+
+- **Backend:** pytest for Python tests
+- **Frontend:** Vitest for React component and page tests
+
+```bash
+# Run backend tests
+pytest tests/
+
+# Run frontend tests
+cd frontend
+npm test
+
+# Run a specific test file
+npm test -- Translator
+```
+
+When to include tests:
+- New features: add tests covering the core behavior
+- Bug fixes: add a test that reproduces the bug and verifies the fix
+- Refactors: ensure existing tests still pass, add tests if coverage gaps exist
+
+When tests are optional:
+- Pure styling/CSS changes
+- Documentation updates
+- Config file changes
+
+## Commit messages
-### Basic rules
+Use conventional commit style:
-- `master` contains only stable releases (which have been merged to `master`) and is intended for end-users.
-- `development` is the target for testing (around 10% of users) and is not intended for end-users looking for stability.
-- `feature` is a temporary feature branch based on `development`.
+```
+feat(translator): add batch retry for failed jobs
+fix(ui): search field not clearing on page change
+refactor(scraper): simplify response parsing
+```
-### Conditions
+## Submodules
-- `master` is not merged back to `development`.
-- `development` is not re-based on `master`.
-- all `feature` branches are branched from `development` only.
-- Bugfixes created specifically for a feature branch are done there (because they are specific, they're not cherry-picked to `development`).
-- We will not release a patch (1.0.x) if a newer minor (1.x.0) has already been released. We only go forward.
+Bazarr+ includes two submodules:
+- `opensubtitles-scraper` - OpenSubtitles.org web scraper service
+- `ai-subtitle-translator` - AI-powered subtitle translator
-## Typical contribution workflow
+Changes to these should be submitted to their respective repositories:
+- [LavX/opensubtitles-scraper](https://github.com/LavX/opensubtitles-scraper)
+- [LavX/ai-subtitle-translator](https://github.com/LavX/ai-subtitle-translator)
-### Community devs
+## Running locally
-- Fork the repository or pull the latest changes if you already have forked it.
-- Checkout `development` branch.
-- Make the desired changes.
-- Submit a PR to Bazarr `development` branch.
-- Once reviewed, your PR will be merged using Squash and Merge with a meaningful commit message matching our standards.
+```bash
+# Clone with submodules
+git clone --recursive https://github.com/LavX/bazarr.git
+cd bazarr
-### Official devs team
+# Backend
+pip install -r requirements.txt
+python bazarr.py --no-update --config ./config
-- All commits must have a meaningful commit message (ex.: Fixed issue with this, Improved process abc, Added input field to UI, etc.).
-- Fixes can be made directly to `development` branch but keep in mind that a pre-release with a beta versioning will be created every day a new push is made.
-- Features must be developed in dedicated feature branch and merged back to `development` branch using PR.
-- Once reviewed, your PR will be merged by morpheus65535 using Squash and Merge with a meaningful message.
+# Frontend (separate terminal)
+cd frontend
+npm ci
+npm start
+```
diff --git a/Dockerfile b/Dockerfile
index 86ddab04ca..7daf41e6ea 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,5 +1,5 @@
# =============================================================================
-# Bazarr LavX Fork - Production Docker Image
+# Bazarr+ Production Docker Image
# =============================================================================
# Multi-stage build optimized for layer caching
# Based on Debian Slim for better compatibility (unrar, etc.)
@@ -42,8 +42,8 @@ ARG BAZARR_VERSION
ARG BUILD_DATE
ARG VCS_REF
-LABEL org.opencontainers.image.title="Bazarr (LavX Fork)" \
- org.opencontainers.image.description="Bazarr with OpenSubtitles.org scraper support" \
+LABEL org.opencontainers.image.title="Bazarr+" \
+ org.opencontainers.image.description="Bazarr+ - enhanced subtitle management" \
org.opencontainers.image.version="${BAZARR_VERSION}" \
org.opencontainers.image.created="${BUILD_DATE}" \
org.opencontainers.image.revision="${VCS_REF}" \
@@ -94,7 +94,7 @@ COPY bazarr ./bazarr
# Write version to VERSION file so bazarr/main.py can read it
RUN echo "${BAZARR_VERSION}" > /app/bazarr/VERSION
-# Copy fork identification file (shows "LavX Fork" in System Status)
+# Copy package identification file (shows version in System Status)
COPY package_info /app/bazarr/package_info
# Copy pre-built frontend (built in GitHub Actions workflow for caching)
@@ -102,11 +102,9 @@ COPY package_info /app/bazarr/package_info
COPY frontend/build ./frontend/build
# Set environment variables
-# PYTHON_JIT=1 enables the experimental JIT compiler (Python 3.13+)
ENV HOME="/config" \
PYTHONDONTWRITEBYTECODE=1 \
- PYTHONUNBUFFERED=1 \
- PYTHON_JIT=1
+ PYTHONUNBUFFERED=1
# Volume for persistent data
VOLUME /config
@@ -116,7 +114,7 @@ EXPOSE 6767
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
- CMD curl -f http://localhost:6767/api/system/health || exit 1
+ CMD curl -f http://localhost:6767/api/system/ping || exit 1
ENTRYPOINT ["/entrypoint.sh"]
CMD ["python", "bazarr.py", "--no-update", "--config", "/config"]
\ No newline at end of file
diff --git a/README.md b/README.md
index bec72fea81..73183295da 100644
--- a/README.md
+++ b/README.md
@@ -1,40 +1,66 @@
-# Bazarr (LavX Fork) - "Neon Pulse"
+
+
+
+
+
+
+# Bazarr+
+
+
- Automated subtitle management with OpenSubtitles.org scraper & AI-powered translation
+ Enhanced subtitle management built on Bazarr
- This fork of Bazarr includes:
- - OpenSubtitles.org provider that works without VIP API credentials (survives the API shutdown)
- - AI Subtitle Translator using OpenRouter LLMs for high-quality subtitle translation
- - Batch translation for entire series/movie libraries
- - Advanced table filters with collapsible panels, active filter pills, and audio language filtering
+ No tracking ยท Provider priority ยท OpenSubtitles.org web scraper ยท AI translation via OpenRouter (300+ LLMs) ยท API key encryption ยท batch translation ยท mass subtitle sync ยท 11 bulk operations ยท subtitle viewer ยท advanced table filters ยท security hardening ยท Python 3.14 ยท navy + amber dark theme
---
-## OpenSubtitles.org API Shutdown Notice
-
-On **January 29, 2026**, OpenSubtitles.org [announced the final shutdown](https://forum.opensubtitles.org/viewtopic.php?t=19471) of their legacy XML-RPC API for **all third-party applications** -- both VIP and non-VIP users. The shutdown is taking effect in the coming weeks.
+## Switching from upstream Bazarr?
-**This fork is not affected.** It includes a self-hosted web scraper that accesses OpenSubtitles.org directly through the website, bypassing the API entirely. As long as the OpenSubtitles.org website remains accessible ([confirmed by the site admin](https://forum.opensubtitles.org/viewtopic.php?t=19471) to stay available as read-only), subtitle search and download will continue to work.
+- Migration can be as simple as replacing the container image with `ghcr.io/lavx/bazarr:latest` and starting the container
+- Back up your `/config` directory first
+- Bazarr+ uses independent versioning starting at v2.0.0, unrelated to upstream version numbers
+- Config changes made by Bazarr+ are not backwards-compatible with upstream Bazarr, so switching back requires restoring your backup
+- Recommended: test with a copy of your config before committing to the switch
-## Why This Fork Exists
-
-This fork was created after the [pull request (#3012)](https://github.com/morpheus65535/bazarr/pull/3012) to add OpenSubtitles.org web scraper support was declined by the upstream maintainer. The upstream decision is understandable -- they have agreements with OpenSubtitles and want to respect their infrastructure.
+---
-However, with the official API now being shut down, this fork with its scraper companion container is one of the few ways to maintain access to OpenSubtitles.org subtitles. The web scraper is rate-limited and respectful to their servers.
+## At a Glance
+
+| Feature | Upstream Bazarr | Bazarr+ |
+|---------|-----------------|---------|
+| **Provider Priority** | [Rejected](https://bazarr.featureupvote.com/suggestions/112323/provider-prioritization) (62 votes) | Dual mode: priority order with early stop, or classic simultaneous |
+| **OpenSubtitles.org (Scraper)** | Not available | Self-hosted FastAPI microservice via CloudScraper |
+| **AI Subtitle Translator (OpenRouter)** | Not available | 300+ LLMs + any custom model ID |
+| **API Key Encryption** | Not available | AES-256-GCM encryption for keys in transit |
+| **Translate from Missing Menu** | Not available | Action menu on missing subs with source language picker |
+| **Batch Translation** | Not available | Translate entire series/libraries from Wanted pages |
+| **Mass Subtitle Sync** | [Rejected](https://bazarr.featureupvote.com/suggestions/172013/mass-sync-all-subtitles) (249 votes) | Bulk sync from Tasks page or Mass Edit, skips already-synced |
+| **Bulk Operations** | One-at-a-time only | 11 batch actions: sync, translate, OCR fixes, common fixes, remove HI, remove tags, fix uppercase, reverse RTL, scan disk, search missing, upgrade (up to 10k items) |
+| **Dedicated Translator Settings** | Not available | 4-zone page with pricing, cost estimates, status panel |
+| **No Tracking** | GA4 + legacy UA phone home to Google | All telemetry removed, nothing phones home |
+| **Security Hardening** | MD5, no CSRF/SSRF/rate limiting | PBKDF2 (600k iter), CSRF, SSRF, brute-force, 4 more |
+| **Subtitle Viewer** | Not available | Read-only subtitle preview with SRT/VTT/ASS parsing, cue table, and format detection |
+| **Audio Language Display** | Not shown in tables | Badges in all table views |
+| **Advanced Table Filters** | No filters | Include/exclude audio, missing subtitle, title search |
+| **Floating Save + Ctrl+S** | Not available | Sticky save button with 3-option unsaved changes modal |
+| **Navy + Amber Theme** | Purple | `#121125` navy to `#fff8e1` cream, amber accents |
+| OpenSubtitles.com (API) | Available | Available |
+| Docker images | linuxserver.io / hotio | ghcr.io/lavx (self-built, multi-arch) |
+| Python runtime | 3.8-3.13 | 3.14 |
---
-## ๐ Quick Start
+## Quick Start
### Option 1: Docker Compose (Recommended)
@@ -60,66 +86,104 @@ docker pull ghcr.io/lavx/ai-subtitle-translator:latest
---
-## ๐ What's Different in This Fork?
+### Screenshots
-| Feature | Upstream Bazarr | LavX Fork |
-|---------|-----------------|-----------|
-| **OpenSubtitles.org (Scraper)** | โ Not available | โ
Included (API-independent) |
-| **AI Subtitle Translator** | โ Not available | โ
Included (OpenRouter, Gemini, Lingarr) |
-| **Batch Translation** | โ Not available | โ
Translate entire series/libraries |
-| **Audio Language Display** | โ Not shown in tables | โ
Audio languages visible in all table views |
-| **Advanced Table Filters** | โ Basic search only | โ
Collapsible filter panel with active filter pills |
-| OpenSubtitles.org (API) | Shutting down | N/A (uses scraper instead) |
-| OpenSubtitles.com (API) | โ
Available | โ
Available |
-| Docker images | linuxserver/hotio | ghcr.io/lavx |
-| Python runtime | 3.11/3.12 | 3.14 with JIT |
-| Fork identification in UI | N/A | โ
"LavX Fork - Neon Pulse" |
+| Series with batch actions | Mass translate dialog |
+|:---:|:---:|
+|  |  |
-### ๐ฏ OpenSubtitles.org Scraper Provider
+| Series detail with fanart | Subtitle viewer |
+|:---:|:---:|
+|  |  |
-This fork adds a **new subtitle provider** called "OpenSubtitles.org" that:
+| AI Translator settings |
+|:---:|
+|  |
-- โ
Works **without** API credentials or VIP subscription
-- โ
Searches by IMDB ID for accurate results
-- โ
Supports both movies and TV shows
-- โ
Provides subtitle rating and download count info
-- โ
Runs as a separate microservice for reliability
+---
-### ๐ค AI Subtitle Translator
+
+Feature Details
-This fork includes an **LLM-powered subtitle translator** that:
+### OpenSubtitles.org Web Scraper
+OpenSubtitles.org shut down their XML-RPC API for all third-party apps, VIP included. Bazarr+ ships a self-hosted FastAPI microservice that scrapes OpenSubtitles.org directly via CloudScraper with optional FlareSolverr fallback. It provides search, subtitle listing, and download endpoints (`/api/v1/search`, `/api/v1/subtitles`, `/api/v1/download/subtitle`) and integrates into Bazarr's provider system through a mixin class. No API key or VIP subscription needed.
-- โ
Uses **OpenRouter API** for access to 100+ AI models (Gemini, GPT, Claude, LLaMA, Grok, etc.)
-- โ
Translates subtitles when no good match is found in your target language
-- โ
**Async job queue** for handling multiple translations
-- โ
Real-time **progress tracking** in Bazarr UI
-- โ
Configurable directly in Bazarr Settings (API key, model, temperature, concurrent jobs)
-- โ
Runs as a separate microservice for reliability
+### Provider Priority
+Upstream Bazarr queries all subtitle providers simultaneously and picks the highest-scored result. There's no way to prefer one provider over another. This has been [requested for 6 years](https://bazarr.featureupvote.com/suggestions/112323/provider-prioritization) (62 votes), but upstream rejected it as "won't happen," calling it a "major rework" that "would take months of development."
-**Repository:** [github.com/LavX/ai-subtitle-translator](https://github.com/LavX/ai-subtitle-translator)
+Bazarr+ solves it with a **Provider Priority toggle** in Settings > Providers. When enabled, providers are queried sequentially in the order you've arranged them. If a provider returns subtitles meeting the minimum score, Bazarr+ stops searching and uses those results. Your preferred providers (curated community sites, specialized language sources) always get first shot. When disabled, the original behavior is preserved: all providers queried simultaneously, best score wins.
-### ๐ Advanced Table Filters
+### AI Subtitle Translation via OpenRouter
+Upstream has Google Translate, Gemini, and Lingarr. Bazarr+ adds **OpenRouter** as a fourth translator engine, giving access to 300+ LLMs (Claude, Gemini, GPT, LLaMA, Grok, and more) plus any custom model ID from openrouter.ai. It runs as a separate microservice with an async job queue supporting 1-5 concurrent jobs and 1-8 parallel batches. Features include:
+- **Translate from the subtitle action menu**: click (...) on a missing subtitle row, pick an existing source subtitle to translate from
+- **Batch translation** for entire series/movie libraries from the Wanted pages
+- **Dedicated settings page** with 4 zones: engine picker, connection config, model tuning (temperature, reasoning mode, parallel batches), and a live status panel showing queue stats, job progress, token usage, cost, and speed
+- **Model details** fetched live from the OpenRouter API with per-million token pricing, per-episode/movie cost estimates, context length, and prompt caching indicators
+- **AES-256-GCM encryption** for API keys in transit between Bazarr and the translator service, with a Test Connection button that validates encryption and API key status before saving
+- **Auto disk scan** triggers Sonarr/Radarr to rescan after translation completes
-All table views (Movies, Series, Wanted Movies, Wanted Series) feature a **sophisticated filter system**:
+### Subtitle Viewer
+Read-only subtitle preview accessible from the subtitle action menu. Supports SRT, VTT, and ASS/SSA formats with automatic format detection. Shows a cue table with timestamps and text, file size, and format badge. Useful for quickly checking subtitle content and timing without downloading.
-- โ
**Collapsible filter panel** toggled via a filter button with active filter count badge
-- โ
**Include/Exclude audio language** filters with labeled, searchable multi-select dropdowns
-- โ
**Missing subtitle language** filter (on Wanted pages)
-- โ
**Active filter pills** showing each active filter as a color-coded removable badge
-- โ
**Clear all** button to reset all filters at once
-- โ
**Search by title** with inline clear button
+### Advanced UI
+- **Table filters** on Wanted and Library pages: include/exclude audio language (multi-select), missing subtitle language filter, title search, with active filter chips and a collapsible filter panel
+- **Floating save button** with Ctrl+S/Cmd+S keyboard shortcut, visible only when settings have unsaved changes
+- **Three-button unsaved changes modal**: Save & Leave, Discard, or Keep Editing (upstream only has Leave/Stay)
+- **Navy + amber dark theme**: custom color palette from `#121125` (navy black) to `#fff8e1` (cream), with amber brand accents (`#e68a00` to `#b36b00`)
+- **Audio language display** as blue badges in all table views
-| Wanted Movies with filters | Series with audio filter |
-|:---:|:---:|
-|  |  |
+### Mass Subtitle Sync
+Upstream lets you sync subtitles one at a time, or per-series via Mass Edit. But there's no way to sync your entire library at once. This has been [requested for years](https://bazarr.featureupvote.com/suggestions/172013/mass-sync-all-subtitles) (249 votes), but upstream rejected it as "won't happen," saying "Bazarr isn't a batch tool."
-| Mass Translate with filtered selection |
-|:---:|
-|  |
+Bazarr+ adds two entry points for bulk sync:
+- **System Tasks page**: a "Mass Sync All Subtitles" task with a Run button that syncs every subtitle in your library
+- **Mass Edit pages**: a "Sync Subtitles" button for both Movies and Series editors, so you can select specific items and sync their subtitles in bulk
----
+Both use the existing ffsubsync engine. Already-synced subtitles are skipped by default (with a force re-sync option). Configurable max offset, Golden-Section Search, and framerate correction settings.
-## ๐ฆ Installation
+### Bulk Operations
+Select multiple movies or series from the library pages and apply operations in batch. Available from the toolbar that appears when items are selected, or from the System Tasks page for library-wide runs. Confirmation is required for operations on 100+ items. Up to 10,000 items per batch.
+
+**Subtitle modifications** (applied to all existing subtitles of selected items):
+- **Sync** -- align subtitle timing to audio using ffsubsync, with configurable max offset (1-600s), Golden-Section Search, framerate correction, and force re-sync
+- **OCR Fixes** -- correct common optical character recognition errors
+- **Common Fixes** -- apply standard subtitle formatting and whitespace corrections
+- **Remove Hearing Impaired** -- strip `[music]`, `(doorbell rings)`, and similar HI annotations
+- **Remove Style Tags** -- remove ``, ``, `` and other formatting tags
+- **Fix Uppercase** -- convert ALL CAPS subtitles to proper case
+- **Reverse RTL** -- fix right-to-left punctuation for Arabic, Hebrew, and similar languages
+- **Translate** -- batch translate subtitles using any configured translator engine (Google, Gemini, Lingarr, or OpenRouter with 300+ LLMs)
+
+**Media operations** (search and scan actions for selected items):
+- **Scan Disk** -- rescan selected items for on-disk subtitle files
+- **Search Missing** -- search all configured providers for missing subtitles
+- **Upgrade** -- replace low-scoring subtitles with better matches from providers
+
+**Profile management:**
+- **Bulk profile assignment** -- select multiple movies or series and assign a language profile to all of them at once
+
+### No Tracking / No Telemetry
+Upstream Bazarr ships two analytics systems that phone home to Google: a GA4 property (`G-3820T18GE3`) in `bazarr/utilities/analytics.py` that reports your Bazarr version, Python version, Sonarr/Radarr versions, OS, subtitle provider usage, every download action, and languages searched, plus a legacy Universal Analytics tracker (`UA-86466078-1`) in the SubZero library dependency. Bazarr+ has removed both entirely. No usage data leaves your server.
+
+### Security Hardening
+8 areas upstream doesn't address:
+- **Password hashing**: PBKDF2-SHA256 with 600,000 iterations and 16-byte random salt (upstream uses plain MD5)
+- **CSRF protection**: cryptographic state tokens (`secrets.token_urlsafe(32)`) on Plex OAuth with timing-safe validation
+- **SSRF blocking**: DNS pinning with IP validation, blocks loopback and link-local addresses, fails closed
+- **Brute-force protection**: 5 failed attempts trigger 300-second lockout per IP, tracks up to 10,000 IPs with thread-safe OrderedDict
+- **Shell injection**: replaced naive character escaping with `shlex.quote()` (POSIX) and `subprocess.list2cmdline()` (Windows)
+- **Filesystem sandboxing**: blocks `/proc`, `/sys`, `/dev`, `/etc`, `/root`, `/tmp` and 4 others from the filesystem browser, resolves symlinks
+- **eval() removal**: replaced `eval()` in throttled provider cache with `json.loads()` to prevent arbitrary code execution
+- **API key comparison**: `hmac.compare_digest()` for constant-time comparison (upstream uses Python `in` operator, which is timing-dependent)
+
+### Python 3.14
+Dockerfile uses `python:3.14-slim-bookworm`. Upstream supports Python 3.8-3.13 and relies on third-party Docker images (LinuxServer.io, hotio). Bazarr+ builds and publishes its own multi-arch image to GHCR.
+
+
+
+
+
+Installation and Configuration
### Docker Compose Setup
@@ -127,13 +191,26 @@ Create a `docker-compose.yml` file:
```yaml
services:
- # OpenSubtitles.org Scraper Service (required for the scraper provider)
+ # FlareSolverr - Handles browser challenges for web scraping
+ flaresolverr:
+ image: ghcr.io/flaresolverr/flaresolverr:latest
+ container_name: flaresolverr
+ restart: unless-stopped
+ ports:
+ - "8191:8191"
+ environment:
+ - LOG_LEVEL=info
+
opensubtitles-scraper:
image: ghcr.io/lavx/opensubtitles-scraper:latest
container_name: opensubtitles-scraper
restart: unless-stopped
+ depends_on:
+ - flaresolverr
ports:
- "8000:8000"
+ environment:
+ - FLARESOLVERR_URL=http://flaresolverr:8191/v1
healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:8000/health"]
interval: 30s
@@ -150,14 +227,14 @@ services:
environment:
# OpenRouter API key (can also be configured in Bazarr UI)
- OPENROUTER_API_KEY=${OPENROUTER_API_KEY:-}
- - OPENROUTER_DEFAULT_MODEL=google/gemini-2.5-flash-preview-05-20
+ - OPENROUTER_DEFAULT_MODEL=google/gemini-2.5-flash-lite-preview-09-2025
healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:8765/health"]
interval: 30s
timeout: 10s
retries: 3
- # Bazarr with OpenSubtitles.org scraper support
+ # Bazarr+
bazarr:
image: ghcr.io/lavx/bazarr:latest
container_name: bazarr
@@ -173,8 +250,6 @@ services:
- PUID=1000
- PGID=1000
- TZ=Europe/Budapest
- # Enable the web scraper mode (auto-enables "Use Web Scraper" in settings)
- - OPENSUBTITLES_USE_WEB_SCRAPER=true
# Point to the scraper service (port 8000)
- OPENSUBTITLES_SCRAPER_URL=http://opensubtitles-scraper:8000
volumes:
@@ -195,32 +270,40 @@ docker compose up -d
### Environment Variables
-| Variable | Description | Default |
-|----------|-------------|---------|
-| `PUID` | User ID for file permissions | `1000` |
-| `PGID` | Group ID for file permissions | `1000` |
-| `TZ` | Timezone | `UTC` |
-| `OPENSUBTITLES_USE_WEB_SCRAPER` | Enable web scraper mode | `false` |
-| `OPENSUBTITLES_SCRAPER_URL` | URL of the scraper service | `http://localhost:8000` |
+| Variable | Default | Description |
+|----------|---------|-------------|
+| `PUID` | `1000` | User ID for file permissions |
+| `PGID` | `1000` | Group ID for file permissions |
+| `TZ` | `UTC` | Timezone (e.g., `Europe/Budapest`) |
+| `OPENSUBTITLES_SCRAPER_URL` | `http://opensubtitles-scraper:8000` | OpenSubtitles.org scraper service URL (port 8000, not 8765) |
+
+### Volumes
+
+| Path | Description |
+|------|-------------|
+| `/config` | Bazarr configuration and database |
+| `/movies` | Movies library (match your Radarr path) |
+| `/tv` | TV shows library (match your Sonarr path) |
### Enabling the Provider
-1. Go to **Settings** โ **Providers**
-2. Enable **"OpenSubtitles.org"** (not OpenSubtitles.com - that's the API version)
-3. If `OPENSUBTITLES_USE_WEB_SCRAPER=true` is set, "Use Web Scraper" will auto-enable
+1. Go to **Settings** > **Providers**
+2. Enable **"OpenSubtitles.org"** (not OpenSubtitles.com, that's the API version)
+3. Set the scraper service URL (or use the `OPENSUBTITLES_SCRAPER_URL` env var)
4. Save and test with a manual search
### Enabling AI Translation
-1. Go to **Settings** โ **Subtitles** โ **Translating**
-2. Select **"AI Subtitle Translator"** from the Translator dropdown
+1. Go to **Settings** > **AI Translator**
+2. Select **"AI Subtitle Translator"** as the translator engine
3. Enter your **OpenRouter API Key** (get one at [openrouter.ai/keys](https://openrouter.ai/keys))
-4. Choose your preferred **AI Model** (Gemini 2.5 Flash recommended)
+4. Choose your preferred **AI Model** (Google: Gemini 2.5 Flash Lite Preview 09-2025 recommended)
5. Save and test with a manual translation
----
+
-## ๐๏ธ Architecture
+
+Architecture
```
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
@@ -228,7 +311,7 @@ docker compose up -d
โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโ โ
โ โ Bazarr โ โ OpenSubtitles Scraper โ โ AI Sub โ โ
-โ โ (LavX Fork) โ โ (Port 8000) โ โ Translator โ โ
+โ โ (Bazarr+) โ โ (Port 8000) โ โ Translator โ โ
โ โ โ โ โ โ (Port 8765) โ โ
โ โ โโโโโโโโโโโโโโโโโโโโ โ HTTP โ โโโโโโโโโโโโโโโโโโโ โ โ โ โ
โ โ โ OpenSubtitles.orgโโโโผโโโโโโโผโโโ Search API โ โ โ โโโโโโโโโโโ โ โ
@@ -247,30 +330,12 @@ docker compose up -d
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
```
----
+> **Note:** The scraper service can optionally use [FlareSolverr](https://github.com/FlareSolverr/FlareSolverr) (port 8191) to handle browser challenges. See the Docker Compose example above for the full setup.
-## ๐ ๏ธ Configuration Options
+
-### Environment Variables
-
-| Variable | Default | Description |
-|----------|---------|-------------|
-| `PUID` | `1000` | User ID for file permissions |
-| `PGID` | `1000` | Group ID for file permissions |
-| `TZ` | `UTC` | Timezone (e.g., `Europe/Budapest`) |
-| `OPENSUBTITLES_SCRAPER_URL` | `http://opensubtitles-scraper:8765` | Scraper service URL |
-
-### Volumes
-
-| Path | Description |
-|------|-------------|
-| `/config` | Bazarr configuration and database |
-| `/movies` | Movies library (match your Radarr path) |
-| `/tv` | TV shows library (match your Sonarr path) |
-
----
-
-## ๐ง Troubleshooting
+
+Troubleshooting
### Scraper Connection Issues
@@ -293,116 +358,22 @@ curl -X POST http://localhost:8000/search \
|-------|----------|
| "Connection refused" | Ensure scraper is running and healthy |
| "No subtitles found" | Check IMDB ID is correct, try different language |
-| Provider not showing | Enable it in Settings โ Providers |
+| Provider not showing | Enable it in Settings > Providers |
| Wrong file permissions | Check PUID/PGID match your user |
----
-
-## ๐ Documentation
-
-- [Fork Maintenance Guide](docs/FORK_MAINTENANCE.md) - How sync works
-- [OpenSubtitles Scraper](https://github.com/LavX/opensubtitles-scraper) - Scraper docs
-- [AI Subtitle Translator](https://github.com/LavX/ai-subtitle-translator) - AI translator docs
-- [Bazarr Wiki](https://wiki.bazarr.media) - General Bazarr documentation
-
----
-
-## ๐ค Contributing
-
-Contributions are welcome! Please:
-
-1. Fork this repository
-2. Create a feature branch
-3. Submit a pull request
-
-For major changes, please open an issue first.
-
----
-
-## ๐ About the Maintainer
-
-This fork is maintained by **LavX**. Explore more of my projects and services:
-
-### ๐ Services
-- **[LavX Managed Systems](https://lavx.hu)** โ Enterprise AI solutions, RAG systems, and LLMOps.
-- **[LavX News](https://news.lavx.hu)** โ Latest insights on AI, Open Source, and emerging tech.
-- **[LMS Tools](https://tools.lavx.hu)** โ 140+ free, privacy-focused online tools for developers and researchers.
-
-### ๐ ๏ธ Open Source Projects
-- **[AI Subtitle Translator](https://github.com/LavX/ai-subtitle-translator)** โ LLM-powered subtitle translator using OpenRouter API.
-- **[OpenSubtitles Scraper](https://github.com/LavX/opensubtitles-scraper)** โ Web scraper for OpenSubtitles.org (no VIP required).
-- **[JFrog to Nexus OSS](https://github.com/LavX/jfrogtonexusoss)** โ Automated migration tool for repository managers.
-- **[WeatherFlow](https://github.com/LavX/weatherflow)** โ Multi-platform weather data forwarding (WU to Windy/Idokep).
-- **[Like4Like Suite](https://github.com/LavX/Like4Like-Suite)** โ Social media automation and engagement toolkit.
-
----
-
-## ๐ License
-
-- [GNU GPL v3](http://www.gnu.org/licenses/gpl.html)
-- Original Bazarr Copyright 2010-2025 morpheus65535
-- Fork modifications Copyright 2025 LavX
-
----
+
-๐ Original Bazarr README
-
-# bazarr
-
-Bazarr is a companion application to Sonarr and Radarr. It manages and downloads subtitles based on your requirements. You define your preferences by TV show or movie and Bazarr takes care of everything for you.
-
-Be aware that Bazarr doesn't scan disk to detect series and movies: It only takes care of the series and movies that are indexed in Sonarr and Radarr.
-
-Thanks to the folks at OpenSubtitles for their logo that was an inspiration for ours.
+Supported Subtitle Providers
-## Support on Paypal
-
-At the request of some, here is a way to demonstrate your appreciation for the efforts made in the development of Bazarr:
-[](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=XHHRWXT9YB7WE&source=url)
-
-## Status (LavX Fork)
-
-[](https://github.com/LavX/bazarr/issues)
-[](https://github.com/LavX/bazarr/stargazers)
-[](https://github.com/LavX/bazarr/network)
-[](https://github.com/LavX/bazarr/actions/workflows/build-docker.yml)
-## Support
-
-For installation and configuration instructions, see upstream [wiki](https://wiki.bazarr.media).
-
-For fork-specific issues (OpenSubtitles scraper), open an issue on [this fork](https://github.com/LavX/bazarr/issues).
-
-For general Bazarr issues, please use the [upstream repo](https://github.com/morpheus65535/bazarr/issues).
-
-Original Bazarr Discord: [](https://discord.gg/MH2e2eb)
-
-## Feature Requests
-
-If you need something that is not already part of Bazarr, feel free to create a feature request on [Feature Upvote](http://features.bazarr.media).
-
-## Major Features Include:
-
-- Support for major platforms: Windows, Linux, macOS, Raspberry Pi, etc.
-- Automatically add new series and episodes from Sonarr
-- Automatically add new movies from Radarr
-- Series or movies based configuration for subtitles languages
-- Scan your existing library for internal and external subtitles and download any missing
-- Keep history of what was downloaded from where and when
-- Manual search so you can download subtitles on demand
-- Upgrade subtitles previously downloaded when a better one is found
-- Ability to delete external subtitles from disk
-- Currently support 184 subtitles languages with support for forced/foreign subtitles (depending of providers)
-- And a beautiful UI based on Sonarr
-
-## Supported subtitles providers:
+Includes all upstream providers plus fork additions:
- Addic7ed
- AnimeKalesi
-- Animetosho (requires AniDb HTTP API client described [here](https://wiki.anidb.net/HTTP_API_Definition))
+- Animetosho (requires [AniDb HTTP API client](https://wiki.anidb.net/HTTP_API_Definition))
- AnimeSub.info
- Assrt
-- AvistaZ, CinemaZ (Get session cookies using method described [here](https://github.com/morpheus65535/bazarr/pull/2375#issuecomment-2057010996))
+- AvistaZ, CinemaZ
- BetaSeries
- BSplayer
- Embedded Subtitles
@@ -419,7 +390,7 @@ If you need something that is not already part of Bazarr, feel free to create a
- Napisy24
- Nekur
- OpenSubtitles.com
-- OpenSubtitles.org (LavX Fork)
+- **OpenSubtitles.org (Bazarr+ web scraper, no API needed)**
- Podnapisi
- RegieLive
- Sous-Titres.eu
@@ -443,15 +414,55 @@ If you need something that is not already part of Bazarr, feel free to create a
- Turkcealtyazi.org
- TuSubtitulo
- TVSubtitles
-- Whisper (requires [ahmetoner/whisper-asr-webservice](https://github.com/ahmetoner/whisper-asr-webservice))
+- Whisper (requires [whisper-asr-webservice](https://github.com/ahmetoner/whisper-asr-webservice))
- Wizdom
- XSubs
- Yavka.net
- YIFY Subtitles
- Zimuku
-## Screenshot
+
+
+
+About the Maintainer
+
+This fork is maintained by **LavX**. Explore more projects and services:
+
+### Services
+- **[LavX Managed Systems](https://lavx.hu)** -- Enterprise AI solutions, RAG systems, and LLMOps.
+- **[LavX News](https://news.lavx.hu)** -- Latest insights on AI, Open Source, and emerging tech.
+- **[LMS Tools](https://tools.lavx.hu)** -- 140+ free, privacy-focused online tools for developers and researchers.
-
+### Open Source Projects
+- **[AI Subtitle Translator](https://github.com/LavX/ai-subtitle-translator)** -- LLM-powered subtitle translator using OpenRouter API.
+- **[OpenSubtitles Scraper](https://github.com/LavX/opensubtitles-scraper)** -- Web scraper for OpenSubtitles.org (no VIP required).
+- **[JFrog to Nexus OSS](https://github.com/LavX/jfrogtonexusoss)** -- Automated migration tool for repository managers.
+- **[WeatherFlow](https://github.com/LavX/weatherflow)** -- Multi-platform weather data forwarding (WU to Windy/Idokep).
+- **[Like4Like Suite](https://github.com/LavX/Like4Like-Suite)** -- Social media automation and engagement toolkit.
-
\ No newline at end of file
+
+
+---
+
+## Documentation
+
+- [Fork Maintenance Guide](docs/FORK_MAINTENANCE.md) - How sync works
+- [OpenSubtitles Scraper](https://github.com/LavX/opensubtitles-scraper) - Scraper docs
+- [AI Subtitle Translator](https://github.com/LavX/ai-subtitle-translator) - AI translator docs
+- [Bazarr Wiki](https://wiki.bazarr.media) - General Bazarr documentation
+
+## Contributing
+
+Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for the full guide.
+
+1. Fork this repository
+2. Create a feature branch from `development`
+3. Submit a PR targeting `development`
+
+For major changes, please open an issue first to discuss.
+
+## License
+
+- [GNU GPL v3](http://www.gnu.org/licenses/gpl.html)
+- Based on [Bazarr](https://github.com/morpheus65535/bazarr) by morpheus65535
+- Fork modifications Copyright 2025-2026 LavX
diff --git a/ai-subtitle-translator b/ai-subtitle-translator
index 92b9522343..1ef643b7a9 160000
--- a/ai-subtitle-translator
+++ b/ai-subtitle-translator
@@ -1 +1 @@
-Subproject commit 92b9522343b494b428dc8e0df2b09b6ff87b4e0b
+Subproject commit 1ef643b7a93a977879230526655a2a61817e4b6a
diff --git a/bazarr-binaries b/bazarr-binaries
new file mode 160000
index 0000000000..0710cde74a
--- /dev/null
+++ b/bazarr-binaries
@@ -0,0 +1 @@
+Subproject commit 0710cde74a5d87975119fc7ad3d6d80a20b6fed5
diff --git a/bazarr.py b/bazarr.py
index e5023714de..df9cdbd2ea 100644
--- a/bazarr.py
+++ b/bazarr.py
@@ -8,7 +8,7 @@
import time
from bazarr.app.get_args import args
-from bazarr.literals import EXIT_PYTHON_UPGRADE_NEEDED, EXIT_NORMAL, FILE_RESTART, FILE_STOP, ENV_RESTARTFILE, ENV_STOPFILE, EXIT_INTERRUPT
+from bazarr.literals import EXIT_PYTHON_UPGRADE_NEEDED, EXIT_NORMAL, FILE_RESTART, FILE_STOP, ENV_RESTARTFILE, ENV_STOPFILE, EXIT_INTERRUPT, EXIT_UNEXPECTED_ERROR
# always flush print statements
sys.stdout.reconfigure(line_buffering=True)
@@ -27,8 +27,8 @@ def check_python_version():
print("Python " + minimum_py3_str + " or greater required. "
"Current version is " + platform.python_version() + ". Please upgrade Python.")
exit_program(EXIT_PYTHON_UPGRADE_NEEDED)
- elif int(python_version[0]) == 3 and int(python_version[1]) > 13:
- print("Python version greater than 3.13.x is unsupported. Current version is " + platform.python_version() +
+ elif int(python_version[0]) == 3 and int(python_version[1]) > 14:
+ print("Python version greater than 3.14.x is unsupported. Current version is " + platform.python_version() +
". Keep in mind that even if it works, you're on your own.")
elif (int(python_version[0]) == minimum_py3_tuple[0] and int(python_version[1]) < minimum_py3_tuple[1]) or \
(int(python_version[0]) != minimum_py3_tuple[0]):
@@ -106,27 +106,21 @@ def check_status():
os.remove(restart_file)
except Exception:
print('Unable to delete restart file.')
- finally:
- terminate_child()
- print("Bazarr is restarting...")
- child_process = start_bazarr()
-
+ terminate_child()
+ print("Bazarr is restarting...")
+ child_process = start_bazarr()
+ return
+
+ if not is_process_running(child_process):
+ print("Bazarr child process has stopped unexpectedly. Shutting down...")
+ exit_program(EXIT_UNEXPECTED_ERROR)
-def is_process_running(pid):
- commands = {
- "win": ["tasklist", "/FI", f"PID eq {pid}"],
- "linux": ["ps", "-eo", "pid"],
- "darwin": ["ps", "-ax", "-o", "pid"]
- }
- # Determine OS and execute corresponding command
- for key in commands:
- if sys.platform.startswith(key):
- result = subprocess.run(commands[key], capture_output=True, text=True)
- return str(pid) in result.stdout.split()
+def is_process_running(child_process):
+ status = child_process.poll()
+ # status is exit code if process has stopped, or None if it's still running
+ return status is None
- print("Unsupported OS")
- return False
def interrupt_handler(signum, frame):
# catch and ignore keyboard interrupt Ctrl-C
@@ -137,7 +131,7 @@ def interrupt_handler(signum, frame):
interrupted = True
print('Handling keyboard interrupt...')
else:
- if not is_process_running(child_process.pid):
+ if not is_process_running(child_process):
# this will be caught by the main loop below
raise SystemExit(EXIT_INTERRUPT)
diff --git a/bazarr/api/episodes/history.py b/bazarr/api/episodes/history.py
index bc7ab6a31d..ecd535fb34 100644
--- a/bazarr/api/episodes/history.py
+++ b/bazarr/api/episodes/history.py
@@ -82,6 +82,7 @@ def get(self):
TableEpisodes.path,
TableHistory.language,
TableHistory.score,
+ TableHistory.score_out_of,
TableShows.tags,
TableHistory.action,
TableHistory.video_path,
@@ -117,6 +118,7 @@ def get(self):
'language': x.language,
'profileId': x.profileId,
'score': x.score,
+ 'score_out_of': x.score_out_of,
'tags': x.tags,
'action': x.action,
'video_path': x.video_path,
@@ -154,7 +156,7 @@ def get(self):
del item['profileId']
if item['score']:
- item['score'] = f"{round((int(item['score']) * 100 / 360), 2)}%"
+ item['score'] = f"{round((int(item['score']) * 100 / item['score_out_of']), 2)}%"
# Make timestamp pretty
if item['timestamp']:
diff --git a/bazarr/api/movies/history.py b/bazarr/api/movies/history.py
index 2324fc73f3..8c7f59fcfc 100644
--- a/bazarr/api/movies/history.py
+++ b/bazarr/api/movies/history.py
@@ -80,6 +80,7 @@ def get(self):
TableHistoryMovie.language,
TableMovies.tags,
TableHistoryMovie.score,
+ TableHistoryMovie.score_out_of,
TableHistoryMovie.subs_id,
TableHistoryMovie.provider,
TableHistoryMovie.subtitles_path,
@@ -110,6 +111,7 @@ def get(self):
'profileId': x.profileId,
'tags': x.tags,
'score': x.score,
+ 'score_out_of': x.score_out_of,
'subs_id': x.subs_id,
'provider': x.provider,
'subtitles_path': x.subtitles_path,
@@ -145,7 +147,7 @@ def get(self):
del item['profileId']
if item['score']:
- item['score'] = f"{round((int(item['score']) * 100 / 120), 2)}%"
+ item['score'] = f"{round((int(item['score']) * 100 / item['score_out_of']), 2)}%"
# Make timestamp pretty
if item['timestamp']:
diff --git a/bazarr/api/plex/oauth.py b/bazarr/api/plex/oauth.py
index b72ee7f6d1..1e198bb9bd 100644
--- a/bazarr/api/plex/oauth.py
+++ b/bazarr/api/plex/oauth.py
@@ -18,6 +18,7 @@
from .exceptions import *
from .security import (TokenManager, sanitize_log_data, pin_cache, get_or_create_encryption_key, sanitize_server_url,
encrypt_api_key)
+from app.config import get_ssl_verify
from app.config import settings, write_config
from app.logger import logger
from utilities.plex_utils import _get_library_locations
@@ -223,7 +224,7 @@ def test_plex_connection(uri, token):
f"{uri}/identity",
headers=headers,
timeout=3,
- verify=False
+ verify=get_ssl_verify('plex')
)
latency_ms = int((time.time() - start_time) * 1000)
@@ -318,10 +319,11 @@ def get(self, pin_id):
if not cached_pin:
raise PlexPinExpiredError("PIN not found or expired")
- if state_param:
- stored_state = cached_pin.get('state_token')
- if not stored_state or not get_token_manager().validate_state_token(state_param, stored_state):
+ stored_state = cached_pin.get('state_token')
+ if stored_state:
+ if not state_param or not get_token_manager().validate_state_token(state_param, stored_state):
logger.warning(f"CSRF state validation failed for PIN {pin_id}")
+ abort(403, "Request validation failed")
headers = {
'Accept': 'application/json',
@@ -625,7 +627,7 @@ def get(self):
f"{server_url}/library/sections",
headers=headers,
timeout=10,
- verify=False
+ verify=get_ssl_verify('plex')
)
if lib_response.status_code in (401, 403):
@@ -691,7 +693,7 @@ def get(self):
f"{successful_server_url}/library/sections/{section_key}/all",
headers={'X-Plex-Token': decrypted_token, 'Accept': 'application/json'},
timeout=5,
- verify=False
+ verify=get_ssl_verify('plex')
)
actual_count = 0
@@ -840,7 +842,7 @@ def post(self):
f"{uri}/identity",
headers=headers,
timeout=3,
- verify=False
+ verify=get_ssl_verify('plex')
)
if response.status_code == 200:
diff --git a/bazarr/api/providers/providers_episodes.py b/bazarr/api/providers/providers_episodes.py
index 38a1650ba1..3b75593686 100644
--- a/bazarr/api/providers/providers_episodes.py
+++ b/bazarr/api/providers/providers_episodes.py
@@ -95,7 +95,7 @@ def get(self):
post_request_parser.add_argument('original_format', type=str, required=True,
help='Use original subtitles format from ["True", "False"]')
post_request_parser.add_argument('provider', type=str, required=True, help='Provider name')
- post_request_parser.add_argument('subtitle', type=str, required=True, help='Pickled subtitles as return by GET')
+ post_request_parser.add_argument('subtitle', type=str, required=True, help='Subtitle ID as returned by GET')
@authenticate
@api_ns_providers_episodes.doc(parser=post_request_parser)
diff --git a/bazarr/api/providers/providers_movies.py b/bazarr/api/providers/providers_movies.py
index 344411a7e0..d41124f4d7 100644
--- a/bazarr/api/providers/providers_movies.py
+++ b/bazarr/api/providers/providers_movies.py
@@ -93,7 +93,7 @@ def get(self):
post_request_parser.add_argument('original_format', type=str, required=True,
help='Use original subtitles format from ["True", "False"]')
post_request_parser.add_argument('provider', type=str, required=True, help='Provider name')
- post_request_parser.add_argument('subtitle', type=str, required=True, help='Pickled subtitles as return by GET')
+ post_request_parser.add_argument('subtitle', type=str, required=True, help='Subtitle ID as returned by GET')
@authenticate
@api_ns_providers_movies.doc(parser=post_request_parser)
diff --git a/bazarr/api/subtitles/__init__.py b/bazarr/api/subtitles/__init__.py
index b107acc414..b20653788d 100644
--- a/bazarr/api/subtitles/__init__.py
+++ b/bazarr/api/subtitles/__init__.py
@@ -2,11 +2,15 @@
from .subtitles import api_ns_subtitles
from .subtitles_info import api_ns_subtitles_info
-from .batch_translate import api_ns_batch_translate
+from .batch import api_ns_batch
+from .content import api_ns_subtitle_content
+from .subtitles_contents import api_ns_subtitle_contents
api_ns_list_subtitles = [
api_ns_subtitles,
api_ns_subtitles_info,
- api_ns_batch_translate,
+ api_ns_batch,
+ api_ns_subtitle_content,
+ api_ns_subtitle_contents,
]
diff --git a/bazarr/api/subtitles/batch.py b/bazarr/api/subtitles/batch.py
new file mode 100644
index 0000000000..f0f0615ff5
--- /dev/null
+++ b/bazarr/api/subtitles/batch.py
@@ -0,0 +1,205 @@
+# coding=utf-8
+
+import logging
+from flask_restx import Resource, Namespace, fields
+from sqlalchemy import and_, or_, func
+
+from app.config import settings
+from app.database import TableHistory, TableHistoryMovie, TableEpisodes, TableMovies, database, select
+from app.jobs_queue import jobs_queue
+from subtitles.mass_operations import mass_batch_operation, VALID_ACTIONS
+from ..utils import authenticate
+
+ACTION_LABELS = {
+ 'sync': 'Syncing Subtitles',
+ 'translate': 'Translating Subtitles',
+ 'OCR_fixes': 'Applying OCR Fixes',
+ 'common': 'Applying Common Fixes',
+ 'remove_HI': 'Removing Hearing Impaired Tags',
+ 'remove_tags': 'Removing Style Tags',
+ 'fix_uppercase': 'Fixing Uppercase',
+ 'reverse_rtl': 'Reversing RTL',
+ 'emoji': 'Removing Emoji',
+ 'scan-disk': 'Scanning Disk',
+ 'search-missing': 'Searching Missing Subtitles',
+ 'upgrade': 'Upgrading Subtitles',
+}
+
+logger = logging.getLogger(__name__)
+
+api_ns_batch = Namespace('Batch', description='Unified batch subtitle operations')
+
+
+@api_ns_batch.route('subtitles/batch')
+class BatchOperation(Resource):
+ post_item_model = api_ns_batch.model('BatchItem', {
+ 'type': fields.String(required=True, description='Type: "episode", "movie", or "series"'),
+ 'sonarrSeriesId': fields.Integer(description='Sonarr Series ID'),
+ 'sonarrEpisodeId': fields.Integer(description='Sonarr Episode ID'),
+ 'radarrId': fields.Integer(description='Radarr Movie ID'),
+ })
+
+ post_options_model = api_ns_batch.model('BatchOptions', {
+ 'max_offset_seconds': fields.Integer(default=60),
+ 'no_fix_framerate': fields.Boolean(default=True),
+ 'gss': fields.Boolean(default=True),
+ 'force_resync': fields.Boolean(default=False),
+ 'from_lang': fields.String(description='Source language code for translate action'),
+ 'to_lang': fields.String(description='Target language code for translate action'),
+ })
+
+ post_request_model = api_ns_batch.model('BatchRequest', {
+ 'items': fields.List(fields.Nested(post_item_model), required=True),
+ 'action': fields.String(required=True, description=f'Action: one of {", ".join(sorted(VALID_ACTIONS))}'),
+ 'options': fields.Nested(post_options_model),
+ })
+
+ post_response_model = api_ns_batch.model('BatchResponse', {
+ 'queued': fields.Integer(description='Number of items processed'),
+ 'skipped': fields.Integer(description='Number of items skipped'),
+ 'errors': fields.List(fields.String(), description='Error messages'),
+ })
+
+ @authenticate
+ @api_ns_batch.response(200, 'Success', post_response_model)
+ @api_ns_batch.response(400, 'Bad Request')
+ @api_ns_batch.response(401, 'Not Authenticated')
+ def post(self):
+ """Execute a batch operation on multiple items"""
+ from flask import request
+ data = request.get_json()
+
+ if not data:
+ return {'error': 'No data provided'}, 400
+
+ action = data.get('action')
+ if not action or action not in VALID_ACTIONS:
+ return {'error': f'Invalid action. Must be one of: {", ".join(sorted(VALID_ACTIONS))}'}, 400
+
+ items = data.get('items')
+ if items is None:
+ return {'error': 'No items provided'}, 400
+
+ if not isinstance(items, list):
+ return {'error': 'items must be a list'}, 400
+
+ if not items:
+ return {'error': 'Empty items list'}, 400
+
+ VALID_ITEM_KEYS = {'type', 'sonarrSeriesId', 'sonarrEpisodeId', 'radarrId'}
+ VALID_TYPES = {'episode', 'movie', 'series'}
+
+ sanitized_items = []
+ for item in items:
+ if not isinstance(item, dict) or item.get('type') not in VALID_TYPES:
+ continue
+ sanitized_items.append({k: v for k, v in item.items() if k in VALID_ITEM_KEYS})
+ items = sanitized_items
+
+ if not items:
+ return {'error': 'No valid items after sanitization'}, 400
+
+ MAX_BATCH_SIZE = 10000
+
+ if len(items) > MAX_BATCH_SIZE:
+ return {'error': f'Batch size exceeds maximum of {MAX_BATCH_SIZE}'}, 400
+
+ options = data.get('options', {})
+
+ label = ACTION_LABELS.get(action, f'Batch {action}')
+ job_name = f"{label} ({len(items)} items)"
+
+ job_id = jobs_queue.feed_jobs_pending_queue(
+ job_name=job_name,
+ module='subtitles.mass_operations',
+ func='mass_batch_operation',
+ kwargs={
+ 'items': items,
+ 'action': action,
+ 'options': options,
+ },
+ is_progress=True,
+ )
+
+ return {'queued': len(items), 'skipped': 0, 'errors': [], 'job_id': job_id}, 200
+
+
+def get_upgradable_media_ids():
+ """Return sets of radarrIds and sonarrSeriesIds that have upgradable subtitles.
+
+ Uses the same latest-row-per-video/language logic as upgrade.py to avoid
+ false positives from old history entries that have already been superseded.
+ """
+ if not settings.general.upgrade_subs:
+ return {'movies': [], 'series': []}
+
+ from subtitles.upgrade import get_queries_condition_parameters
+ minimum_timestamp, query_actions = get_queries_condition_parameters()
+
+ # Movies: only consider the latest history row per (video_path, language)
+ max_movie_ts = select(
+ TableHistoryMovie.video_path,
+ TableHistoryMovie.language,
+ func.max(TableHistoryMovie.timestamp).label('timestamp')
+ ).group_by(
+ TableHistoryMovie.video_path, TableHistoryMovie.language
+ ).distinct().subquery()
+
+ movie_results = database.execute(
+ select(TableHistoryMovie.radarrId)
+ .distinct()
+ .join(TableMovies, TableHistoryMovie.radarrId == TableMovies.radarrId)
+ .join(max_movie_ts, onclause=and_(
+ TableHistoryMovie.video_path == max_movie_ts.c.video_path,
+ TableHistoryMovie.language == max_movie_ts.c.language,
+ TableHistoryMovie.timestamp == max_movie_ts.c.timestamp,
+ ))
+ .where(and_(
+ TableHistoryMovie.action.in_(query_actions),
+ TableHistoryMovie.timestamp > minimum_timestamp,
+ or_(
+ and_(TableHistoryMovie.score.is_(None), TableHistoryMovie.action == 6),
+ TableHistoryMovie.score < TableHistoryMovie.score_out_of - 3
+ )
+ ))
+ ).all()
+ movie_ids = [r.radarrId for r in movie_results]
+
+ # Series: only consider the latest history row per (video_path, language)
+ max_episode_ts = select(
+ TableHistory.video_path,
+ TableHistory.language,
+ func.max(TableHistory.timestamp).label('timestamp')
+ ).group_by(
+ TableHistory.video_path, TableHistory.language
+ ).distinct().subquery()
+
+ series_results = database.execute(
+ select(TableHistory.sonarrSeriesId)
+ .distinct()
+ .join(TableEpisodes, TableHistory.sonarrEpisodeId == TableEpisodes.sonarrEpisodeId)
+ .join(max_episode_ts, onclause=and_(
+ TableHistory.video_path == max_episode_ts.c.video_path,
+ TableHistory.language == max_episode_ts.c.language,
+ TableHistory.timestamp == max_episode_ts.c.timestamp,
+ ))
+ .where(and_(
+ TableHistory.action.in_(query_actions),
+ TableHistory.timestamp > minimum_timestamp,
+ or_(
+ and_(TableHistory.score.is_(None), TableHistory.action == 6),
+ TableHistory.score < TableHistory.score_out_of - 3
+ )
+ ))
+ ).all()
+ series_ids = [r.sonarrSeriesId for r in series_results]
+
+ return {'movies': movie_ids, 'series': series_ids}
+
+
+@api_ns_batch.route('subtitles/upgradable')
+class UpgradableMedia(Resource):
+ @authenticate
+ def get(self):
+ """Return movie and series IDs that have upgradable subtitles"""
+ return get_upgradable_media_ids(), 200
diff --git a/bazarr/api/subtitles/batch_translate.py b/bazarr/api/subtitles/batch_translate.py
deleted file mode 100644
index b6156b2c28..0000000000
--- a/bazarr/api/subtitles/batch_translate.py
+++ /dev/null
@@ -1,113 +0,0 @@
-# coding=utf-8
-
-import logging
-from flask_restx import Resource, Namespace, reqparse, fields
-
-from app.jobs_queue import jobs_queue
-from ..utils import authenticate
-
-logger = logging.getLogger(__name__)
-
-api_ns_batch_translate = Namespace('BatchTranslate', description='Batch translate subtitles')
-
-
-@api_ns_batch_translate.route('subtitles/translate/batch')
-class BatchTranslate(Resource):
- post_request_parser = reqparse.RequestParser()
- post_request_parser.add_argument('items', type=list, location='json', required=True,
- help='List of items to translate')
-
- post_item_model = api_ns_batch_translate.model('BatchTranslateItem', {
- 'type': fields.String(required=True, description='Type: "episode" or "movie"'),
- 'sonarrSeriesId': fields.Integer(description='Sonarr Series ID (for episodes)'),
- 'sonarrEpisodeId': fields.Integer(description='Sonarr Episode ID (for episodes)'),
- 'radarrId': fields.Integer(description='Radarr Movie ID (for movies)'),
- 'sourceLanguage': fields.String(required=True, description='Source language code (e.g., "en")'),
- 'targetLanguage': fields.String(required=True, description='Target language code (e.g., "hu")'),
- 'subtitlePath': fields.String(description='Optional specific subtitle path to translate'),
- 'forced': fields.Boolean(default=False, description='Forced subtitle flag'),
- 'hi': fields.Boolean(default=False, description='Hearing impaired flag'),
- })
-
- post_request_model = api_ns_batch_translate.model('BatchTranslateRequest', {
- 'items': fields.List(fields.Nested(post_item_model), required=True),
- })
-
- post_response_model = api_ns_batch_translate.model('BatchTranslateResponse', {
- 'queued': fields.Integer(description='Number of items queued'),
- 'skipped': fields.Integer(description='Number of items skipped'),
- 'errors': fields.List(fields.String(), description='Error messages'),
- })
-
- @authenticate
- @api_ns_batch_translate.doc(body=post_request_model)
- @api_ns_batch_translate.response(200, 'Success', post_response_model)
- @api_ns_batch_translate.response(400, 'Bad Request')
- @api_ns_batch_translate.response(401, 'Not Authenticated')
- def post(self):
- """Queue batch translation jobs for multiple items"""
- from flask import request
- data = request.get_json()
-
- if not data or 'items' not in data:
- return {'error': 'No items provided'}, 400
-
- items = data.get('items', [])
- if not items:
- return {'error': 'Empty items list'}, 400
-
- queued = 0
- skipped = 0
- errors = []
-
- for item in items:
- try:
- item_type = item.get('type')
- source_language = item.get('sourceLanguage')
- target_language = item.get('targetLanguage')
- forced = item.get('forced', False)
- hi = item.get('hi', False)
- subtitle_path = item.get('subtitlePath')
-
- if not item_type or not source_language or not target_language:
- errors.append(f'Missing required fields in item: {item}')
- skipped += 1
- continue
-
- if item_type == 'episode':
- job_name = f"Translate Episode {item.get('sonarrEpisodeId')}"
- func = 'process_episode_translation'
- elif item_type == 'movie':
- job_name = f"Translate Movie {item.get('radarrId')}"
- func = 'process_movie_translation'
- else:
- errors.append(f'Invalid type "{item_type}" in item')
- skipped += 1
- continue
-
- # Queue the job individually
- jobs_queue.feed_jobs_pending_queue(
- job_name=job_name,
- module='subtitles.tools.translate.batch',
- func=func,
- kwargs={
- 'item': item,
- 'source_language': source_language,
- 'target_language': target_language,
- 'forced': forced,
- 'hi': hi,
- 'subtitle_path': subtitle_path
- }
- )
- queued += 1
-
- except Exception as e:
- logger.error(f'Error queuing batch translate item: {e}', exc_info=True)
- errors.append(str(e))
- skipped += 1
-
- return {
- 'queued': queued,
- 'skipped': skipped,
- 'errors': errors
- }, 200
diff --git a/bazarr/api/subtitles/content.py b/bazarr/api/subtitles/content.py
new file mode 100644
index 0000000000..e3afdc8800
--- /dev/null
+++ b/bazarr/api/subtitles/content.py
@@ -0,0 +1,226 @@
+# coding=utf-8
+
+import ast
+import hashlib
+import os
+
+from flask import make_response, jsonify, request
+from flask_restx import Resource, Namespace
+
+from app.database import TableEpisodes, TableMovies, TableShows, database, select
+from utilities.path_mappings import path_mappings
+
+from ..utils import authenticate
+
+api_ns_subtitle_content = Namespace('SubtitleContent', description='Read subtitle file content')
+
+MAX_FILE_SIZE = 5 * 1024 * 1024 # 5 MB
+
+SUBTITLE_EXTENSIONS = {
+ '.srt', '.ass', '.ssa', '.sub', '.idx', '.sup',
+ '.vtt', '.dfxp', '.ttml', '.smi', '.mpl', '.txt',
+}
+
+FORMAT_MAP = {
+ '.srt': 'srt',
+ '.ass': 'ass',
+ '.ssa': 'ssa',
+ '.vtt': 'vtt',
+ '.sub': 'sub',
+ '.dfxp': 'dfxp',
+ '.ttml': 'ttml',
+ '.smi': 'smi',
+ '.mpl': 'mpl',
+ '.txt': 'txt',
+}
+
+
+def resolve_subtitle_path(media_type, media_id, language_code):
+ """Resolve a subtitle file path from a media ID and language code.
+
+ language_code can be like "en", "hu", "en:hi", "en:forced".
+ Matches against the language field in the subtitles array.
+
+ Returns (path, language, metadata) on success, or (message, status_code) on failure.
+ """
+ metadata = {}
+ if media_type == 'episode':
+ row = database.execute(
+ select(TableEpisodes.subtitles, TableEpisodes.path, TableEpisodes.sonarrSeriesId, TableEpisodes.title)
+ .where(TableEpisodes.sonarrEpisodeId == media_id)
+ ).first()
+ if row:
+ series_row = database.execute(
+ select(TableShows.title).where(TableShows.sonarrSeriesId == row.sonarrSeriesId)
+ ).first()
+ metadata = {
+ 'mediaTitle': series_row.title if series_row else None,
+ 'mediaId': row.sonarrSeriesId,
+ 'episodeTitle': row.title,
+ }
+ elif media_type == 'movie':
+ row = database.execute(
+ select(TableMovies.subtitles, TableMovies.path, TableMovies.title, TableMovies.radarrId)
+ .where(TableMovies.radarrId == media_id)
+ ).first()
+ if row:
+ metadata = {
+ 'mediaTitle': row.title,
+ 'mediaId': row.radarrId,
+ }
+ else:
+ return 'Invalid media type', 400
+
+ if not row:
+ return 'Media not found', 404
+
+ raw_subtitles = row.subtitles
+ if not raw_subtitles:
+ return 'No subtitles found for this media', 404
+
+ try:
+ subtitles_list = ast.literal_eval(raw_subtitles)
+ except (ValueError, SyntaxError):
+ return 'Failed to parse subtitles data', 500
+
+ if not isinstance(subtitles_list, list):
+ return 'Invalid subtitles data', 500
+
+ # Find the subtitle entry matching the language code
+ entry = None
+ for item in subtitles_list:
+ if isinstance(item, list) and len(item) >= 2 and item[0] == language_code:
+ # Must have a file path (not an embedded track)
+ if item[1] and isinstance(item[1], str) and len(item[1]) > 0:
+ entry = item
+ break
+
+ if entry is None:
+ return f'No subtitle found for language "{language_code}"', 404
+
+ language = entry[0]
+ subtitle_path = entry[1]
+
+ if media_type == 'episode':
+ subtitle_path = path_mappings.path_replace(subtitle_path)
+ else:
+ subtitle_path = path_mappings.path_replace_movie(subtitle_path)
+
+ ext = os.path.splitext(subtitle_path)[1].lower()
+ if ext not in SUBTITLE_EXTENSIONS:
+ return f'File does not have a recognized subtitle extension: {ext}', 400
+
+ if not os.path.isfile(subtitle_path):
+ return 'Subtitle file not found on disk', 404
+
+ return subtitle_path, language, metadata
+
+
+def read_subtitle_file(path):
+ """Read a subtitle file and detect its encoding.
+
+ Returns (content_str, encoding) on success.
+ Raises ValueError if the file exceeds MAX_FILE_SIZE.
+ """
+ file_size = os.path.getsize(path)
+ if file_size > MAX_FILE_SIZE:
+ raise ValueError(f'Subtitle file too large ({file_size} bytes, max {MAX_FILE_SIZE})')
+
+ with open(path, 'rb') as f:
+ raw = f.read()
+
+ # BOM detection
+ if raw.startswith(b'\xef\xbb\xbf'):
+ return raw[3:].decode('utf-8'), 'utf-8-sig'
+ if raw.startswith(b'\xff\xfe'):
+ return raw[2:].decode('utf-16-le'), 'utf-16-le'
+ if raw.startswith(b'\xfe\xff'):
+ return raw[2:].decode('utf-16-be'), 'utf-16-be'
+
+ # Try UTF-8
+ try:
+ return raw.decode('utf-8'), 'utf-8'
+ except UnicodeDecodeError:
+ pass
+
+ # charset_normalizer
+ try:
+ import charset_normalizer
+ result = charset_normalizer.from_bytes(raw).best()
+ if result is not None:
+ return str(result), result.encoding
+ except Exception:
+ pass
+
+ # Fallback
+ return raw.decode('cp1252', errors='replace'), 'cp1252'
+
+
+def detect_subtitle_format(path):
+ """Map a subtitle file extension to a format string."""
+ ext = os.path.splitext(path)[1].lower()
+ return FORMAT_MAP.get(ext, 'unknown')
+
+
+def generate_etag(path):
+ """Generate an ETag from file mtime and size."""
+ stat = os.stat(path)
+ tag_input = f"{stat.st_mtime_ns}:{stat.st_size}"
+ return hashlib.md5(tag_input.encode()).hexdigest()
+
+
+def _get_subtitle_content(media_type, media_id, language_code):
+ """Shared handler for episode and movie subtitle content."""
+ result = resolve_subtitle_path(media_type, media_id, language_code)
+
+ # Error tuple
+ if isinstance(result[1], int):
+ return result[0], result[1]
+
+ subtitle_path, language, metadata = result
+
+ etag = generate_etag(subtitle_path)
+
+ # ETag-based caching
+ if_none_match = request.headers.get('If-None-Match')
+ if if_none_match and if_none_match.strip('"') == etag:
+ return '', 304
+
+ try:
+ content, encoding = read_subtitle_file(subtitle_path)
+ except ValueError as e:
+ return str(e), 413
+
+ stat = os.stat(subtitle_path)
+ fmt = detect_subtitle_format(subtitle_path)
+
+ response_data = {
+ 'content': content,
+ 'encoding': encoding,
+ 'format': fmt,
+ 'language': language,
+ 'size': stat.st_size,
+ 'lastModified': stat.st_mtime,
+ }
+ response_data.update(metadata)
+
+ response = make_response(jsonify(response_data))
+
+ response.headers['ETag'] = f'"{etag}"'
+ response.headers['X-Content-Type-Options'] = 'nosniff'
+
+ return response
+
+
+@api_ns_subtitle_content.route('episodes//subtitles//content')
+class EpisodeSubtitleContent(Resource):
+ @authenticate
+ def get(self, sonarrEpisodeId, language):
+ return _get_subtitle_content('episode', sonarrEpisodeId, language)
+
+
+@api_ns_subtitle_content.route('movies//subtitles//content')
+class MovieSubtitleContent(Resource):
+ @authenticate
+ def get(self, radarrId, language):
+ return _get_subtitle_content('movie', radarrId, language)
diff --git a/bazarr/api/subtitles/subtitles.py b/bazarr/api/subtitles/subtitles.py
index 36f5aa90c9..ea9042ed07 100644
--- a/bazarr/api/subtitles/subtitles.py
+++ b/bazarr/api/subtitles/subtitles.py
@@ -146,6 +146,7 @@ def patch(self):
if action == 'sync':
try:
+ postprocess_callback = lambda: postprocess_subtitles(subtitles_path, video_path, media_type, metadata, id)
sync_subtitles(video_path=video_path, srt_path=subtitles_path, srt_lang=language, hi=hi, forced=forced,
percent_score=0, # make sure to always sync when requested manually
reference=args.get('reference') if args.get('reference') not in empty_values else
@@ -159,6 +160,7 @@ def patch(self):
sonarr_episode_id=id if media_type == "episode" else None,
radarr_id=id if media_type == "movie" else None,
force_sync=True,
+ callback=postprocess_callback
)
except OSError:
return 'Unable to edit subtitles file. Check logs.', 409
@@ -189,38 +191,42 @@ def patch(self):
media_type="series" if media_type == "episode" else "movies",
sonarr_series_id=metadata.sonarrSeriesId if media_type == "episode" else None,
sonarr_episode_id=id,
- radarr_id=id)
+ radarr_id=id,
+ metadata=metadata)
+
except OSError:
return 'Unable to edit subtitles file. Check logs.', 409
else:
try:
subtitles_apply_mods(language=language, subtitle_path=subtitles_path, mods=[action],
video_path=video_path)
+ postprocess_subtitles(subtitles_path, video_path, media_type, metadata, id)
except OSError:
return 'Unable to edit subtitles file. Check logs.', 409
- # apply chmod if required
- chmod = int(settings.general.chmod, 8) if not sys.platform.startswith(
- 'win') and settings.general.chmod_enabled else None
- if chmod:
- os.chmod(subtitles_path, chmod)
+ return '', 204
- if media_type == 'episode':
- store_subtitles(path_mappings.path_replace_reverse(video_path), video_path)
- event_stream(type='series', payload=metadata.sonarrSeriesId)
- event_stream(type='episode', payload=id)
- if settings.general.use_plex and settings.plex.update_series_library:
- plex_refresh_item(metadata.imdbId, is_movie=False, season=metadata.season,
- episode=metadata.episode)
- else:
- store_subtitles_movie(path_mappings.path_replace_reverse_movie(video_path), video_path)
- event_stream(type='movie', payload=id)
+def postprocess_subtitles(subtitles_path, video_path, media_type, metadata, id):
+ # apply chmod if required
+ chmod = int(settings.general.chmod, 8) if not sys.platform.startswith('win') and settings.general.chmod_enabled else None
+ if chmod:
+ os.chmod(subtitles_path, chmod)
- if settings.general.use_plex and settings.plex.update_movie_library:
- plex_refresh_item(metadata.imdbId, is_movie=True)
+ if media_type == 'episode':
+ store_subtitles(path_mappings.path_replace_reverse(video_path), video_path)
+ event_stream(type='series', payload=metadata.sonarrSeriesId)
+ event_stream(type='episode', payload=id)
- return '', 204
+ if settings.general.use_plex and settings.plex.update_series_library:
+ plex_refresh_item(metadata.imdbId, is_movie=False, season=metadata.season,
+ episode=metadata.episode)
+ else:
+ store_subtitles_movie(path_mappings.path_replace_reverse_movie(video_path), video_path)
+ event_stream(type='movie', payload=id)
+
+ if settings.general.use_plex and settings.plex.update_movie_library:
+ plex_refresh_item(metadata.imdbId, is_movie=True)
def subtitles_lang_from_filename(path):
diff --git a/bazarr/api/subtitles/subtitles_contents.py b/bazarr/api/subtitles/subtitles_contents.py
new file mode 100644
index 0000000000..81eff96c84
--- /dev/null
+++ b/bazarr/api/subtitles/subtitles_contents.py
@@ -0,0 +1,82 @@
+# coding=utf-8
+import logging
+import srt
+
+from flask_restx import Resource, Namespace, reqparse, fields, marshal
+
+from ..utils import authenticate
+
+
+api_ns_subtitle_contents = Namespace('Subtitle Contents', description='Retrieve contents of subtitle file')
+
+
+@api_ns_subtitle_contents.route('subtitles/contents')
+class SubtitleNameContents(Resource):
+ get_request_parser = reqparse.RequestParser()
+ get_request_parser.add_argument('subtitlePath', type=str, required=True, help='Subtitle filepath')
+
+ time_modal = api_ns_subtitle_contents.model('time_modal', {
+ 'hours': fields.Integer(),
+ 'minutes': fields.Integer(),
+ 'seconds': fields.Integer(),
+ 'total_seconds': fields.Integer(),
+ 'microseconds': fields.Integer(),
+ })
+
+ get_response_model = api_ns_subtitle_contents.model('SubtitlesContentsGetResponse', {
+ 'index': fields.Integer(),
+ 'content': fields.String(),
+ 'proprietary': fields.String(),
+ 'start': fields.Nested(time_modal),
+ 'end': fields.Nested(time_modal),
+ # 'duration': fields.Nested(time_modal),
+ })
+
+ @authenticate
+ @api_ns_subtitle_contents.response(200, 'Success')
+ @api_ns_subtitle_contents.response(401, 'Not Authenticated')
+ @api_ns_subtitle_contents.doc(parser=get_request_parser)
+ def get(self):
+ """Retrieve subtitle file contents"""
+
+ args = self.get_request_parser.parse_args()
+ path = args.get('subtitlePath')
+
+ results = []
+
+ # Load the SRT content
+ with open(path, "r", encoding="utf-8") as f:
+ file_content = f.read()
+
+ # Map contents
+ for sub in srt.parse(file_content):
+
+ start_total_seconds = int(sub.start.total_seconds())
+ end_total_seconds = int(sub.end.total_seconds())
+ duration_timedelta = sub.end - sub.start
+
+ results.append(dict(
+ index=sub.index,
+ content=sub.content,
+ proprietary=sub.proprietary,
+ start=dict(
+ hours = start_total_seconds // 3600,
+ minutes = (start_total_seconds % 3600) // 60,
+ seconds = start_total_seconds % 60,
+ total_seconds=int(sub.start.total_seconds()),
+ microseconds = sub.start.microseconds
+ ),
+ end=dict(
+ hours = end_total_seconds // 3600,
+ minutes = (end_total_seconds % 3600) // 60,
+ seconds = end_total_seconds % 60,
+ total_seconds=int(sub.end.total_seconds()),
+ microseconds = sub.end.microseconds
+ ),
+ # duration=dict(
+ # seconds=int(duration_timedelta.total_seconds()),
+ # microseconds=duration_timedelta.microseconds
+ # ),
+ ))
+
+ return marshal(results, self.get_response_model, envelope='data')
diff --git a/bazarr/api/system/account.py b/bazarr/api/system/account.py
index 1f002c8a25..f8b22b30b4 100644
--- a/bazarr/api/system/account.py
+++ b/bazarr/api/system/account.py
@@ -1,21 +1,77 @@
# coding=utf-8
import gc
+import time
+import secrets
+import logging
+import threading
+from collections import OrderedDict
from flask import session, request
from flask_restx import Resource, Namespace, reqparse
from app.config import settings
-from utilities.helper import check_credentials
+from utilities.helper import check_credentials, needs_password_upgrade, upgrade_password_hash
api_ns_system_account = Namespace('System Account', description='Login or logout from Bazarr UI')
+# In-memory login rate limiter: {ip: (fail_count, last_fail_time)}
+_login_attempts = OrderedDict()
+_login_lock = threading.Lock()
+_MAX_ATTEMPTS = 5
+_LOCKOUT_SECONDS = 300 # 5 minutes
+_MAX_TRACKED_IPS = 10000
+
+
+def _get_client_ip():
+ # Only use remote_addr by default to prevent IP spoofing via headers.
+ # Reverse proxies should be configured to set X-Real-IP reliably.
+ return request.remote_addr
+
+
+def _is_rate_limited(ip):
+ with _login_lock:
+ if ip not in _login_attempts:
+ return False
+ count, last_time = _login_attempts[ip]
+ if time.time() - last_time > _LOCKOUT_SECONDS:
+ del _login_attempts[ip]
+ return False
+ return count >= _MAX_ATTEMPTS
+
+
+def _record_failed_attempt(ip):
+ with _login_lock:
+ # Evict oldest entries if at capacity
+ while len(_login_attempts) >= _MAX_TRACKED_IPS:
+ _login_attempts.popitem(last=False)
+
+ now = time.time()
+ if ip in _login_attempts:
+ count, last_time = _login_attempts[ip]
+ if now - last_time > _LOCKOUT_SECONDS:
+ _login_attempts[ip] = (1, now)
+ else:
+ _login_attempts[ip] = (count + 1, now)
+ else:
+ _login_attempts[ip] = (1, now)
+
+ count = _login_attempts[ip][0]
+ if count >= _MAX_ATTEMPTS:
+ logging.warning(f'Login rate limit triggered for {ip} after {count} failed attempts')
+
+
+def _clear_failed_attempts(ip):
+ with _login_lock:
+ _login_attempts.pop(ip, None)
+
@api_ns_system_account.hide
@api_ns_system_account.route('system/account')
class SystemAccount(Resource):
post_request_parser = reqparse.RequestParser()
- post_request_parser.add_argument('action', type=str, required=True, help='Action from ["login", "logout"]')
+ post_request_parser.add_argument('action', type=str, required=True,
+ help='Action from ["login", "logout", "upgrade_hash"]')
post_request_parser.add_argument('username', type=str, required=False, help='Bazarr username')
post_request_parser.add_argument('password', type=str, required=False, help='Bazarr password')
@@ -24,6 +80,7 @@ class SystemAccount(Resource):
@api_ns_system_account.response(400, 'Unknown action')
@api_ns_system_account.response(403, 'Authentication failed')
@api_ns_system_account.response(406, 'Browser must be closed to invalidate basic authentication')
+ @api_ns_system_account.response(429, 'Too many failed login attempts')
@api_ns_system_account.response(500, 'Unknown authentication type define in config')
def post(self):
"""Login or logout from Bazarr UI when using form login"""
@@ -33,12 +90,23 @@ def post(self):
action = args.get('action')
if action == 'login':
+ ip = _get_client_ip()
+ if _is_rate_limited(ip):
+ return 'Too many failed login attempts. Try again in 5 minutes.', 429
+
username = args.get('username')
password = args.get('password')
if check_credentials(username, password, request):
+ _clear_failed_attempts(ip)
session['logged_in'] = True
+ if needs_password_upgrade():
+ # Store password in session for upgrade (server-side only, never sent to client)
+ session['_pw_for_upgrade'] = password
+ session['_upgrade_token'] = secrets.token_urlsafe(16)
+ return {'upgrade_hash': True, 'upgrade_token': session['_upgrade_token']}, 200
return '', 204
else:
+ _record_failed_attempt(ip)
session['logged_in'] = False
return 'Authentication failed', 403
elif action == 'logout':
@@ -48,5 +116,16 @@ def post(self):
session.clear()
gc.collect()
return '', 204
+ elif action == 'upgrade_hash':
+ # Verify upgrade token from session (no password re-transmission)
+ token = args.get('password') # reuse password field for token
+ stored_token = session.get('_upgrade_token')
+ stored_pw = session.get('_pw_for_upgrade')
+ if not stored_token or not stored_pw or token != stored_token:
+ return 'Invalid or expired upgrade token', 403
+ upgrade_password_hash(stored_pw)
+ session.pop('_pw_for_upgrade', None)
+ session.pop('_upgrade_token', None)
+ return '', 204
return 'Unknown action', 400
diff --git a/bazarr/api/system/releases.py b/bazarr/api/system/releases.py
index 12922dace1..867d795c55 100644
--- a/bazarr/api/system/releases.py
+++ b/bazarr/api/system/releases.py
@@ -18,11 +18,12 @@
@api_ns_system_releases.route('system/releases')
class SystemReleases(Resource):
get_response_model = api_ns_system_releases.model('SystemBackupsGetResponse', {
- 'body': fields.List(fields.String),
+ 'body': fields.String(),
'name': fields.String(),
'date': fields.String(),
'prerelease': fields.Boolean(),
'current': fields.Boolean(),
+ 'repo': fields.String(),
})
@authenticate
@@ -49,12 +50,12 @@ def get(self):
current_version = os.environ["BAZARR_VERSION"]
for i, release in enumerate(filtered_releases):
- body = release['body'].replace('- ', '').split('\n')[1:]
- filtered_releases[i] = {"body": body,
+ filtered_releases[i] = {"body": release['body'] or '',
"name": release['name'],
"date": release['date'][:10],
"prerelease": release['prerelease'],
- "current": release['name'].lstrip('v') == current_version}
+ "current": release['name'].lstrip('v') == current_version,
+ "repo": release.get('repo', 'Bazarr+')}
except Exception:
logging.exception(
diff --git a/bazarr/api/translator/translator.py b/bazarr/api/translator/translator.py
index f75ca0b177..114b99f722 100644
--- a/bazarr/api/translator/translator.py
+++ b/bazarr/api/translator/translator.py
@@ -2,9 +2,12 @@
import logging
import requests
+from flask import request as flask_request
from flask_restx import Resource, Namespace
from app.config import settings
+from app.jobs_queue import jobs_queue
+from subtitles.tools.translate.services.auth import get_translator_auth_headers
from ..utils import authenticate
api_ns_translator = Namespace('Translator', description='AI Subtitle Translator service operations')
@@ -33,11 +36,25 @@ def get(self):
return {"error": "AI Subtitle Translator service URL not configured"}, 503
try:
- response = requests.get(f"{service_url}/api/v1/status", timeout=10)
+ response = requests.get(f"{service_url}/api/v1/status", headers=get_translator_auth_headers(), timeout=10)
if response.status_code == 200:
- return response.json(), 200
+ data = response.json()
+ # Count Bazarr-side pending translation jobs
+ pending_count = sum(
+ 1 for job in jobs_queue.jobs_pending_queue
+ if 'translat' in (job.job_name or '').lower()
+ )
+ running_count = sum(
+ 1 for job in jobs_queue.jobs_running_queue
+ if 'translat' in (job.job_name or '').lower()
+ )
+ data['bazarr_queue'] = {
+ 'pending': pending_count,
+ 'running': running_count,
+ }
+ return data, 200
else:
- return {"error": f"Service returned {response.status_code}"}, response.status_code
+ return {"error": f"Service returned {response.status_code}"}, 502
except requests.exceptions.ConnectionError:
return {"error": "Cannot connect to AI Subtitle Translator service"}, 503
except requests.exceptions.Timeout:
@@ -60,11 +77,11 @@ def get(self):
return {"error": "AI Subtitle Translator service URL not configured"}, 503
try:
- response = requests.get(f"{service_url}/api/v1/jobs", timeout=10)
+ response = requests.get(f"{service_url}/api/v1/jobs", headers=get_translator_auth_headers(), timeout=10)
if response.status_code == 200:
return response.json(), 200
else:
- return {"error": f"Service returned {response.status_code}"}, response.status_code
+ return {"error": f"Service returned {response.status_code}"}, 502
except requests.exceptions.ConnectionError:
return {"error": "Cannot connect to AI Subtitle Translator service"}, 503
except requests.exceptions.Timeout:
@@ -87,13 +104,13 @@ def get(self, job_id):
return {"error": "AI Subtitle Translator service URL not configured"}, 503
try:
- response = requests.get(f"{service_url}/api/v1/jobs/{job_id}", timeout=10)
+ response = requests.get(f"{service_url}/api/v1/jobs/{job_id}", headers=get_translator_auth_headers(), timeout=10)
if response.status_code == 200:
return response.json(), 200
elif response.status_code == 404:
return {"error": "Job not found"}, 404
else:
- return {"error": f"Service returned {response.status_code}"}, response.status_code
+ return {"error": f"Service returned {response.status_code}"}, 502
except requests.exceptions.ConnectionError:
return {"error": "Cannot connect to AI Subtitle Translator service"}, 503
except Exception as e:
@@ -111,13 +128,13 @@ def delete(self, job_id):
return {"error": "AI Subtitle Translator service URL not configured"}, 503
try:
- response = requests.delete(f"{service_url}/api/v1/jobs/{job_id}", timeout=10)
+ response = requests.delete(f"{service_url}/api/v1/jobs/{job_id}", headers=get_translator_auth_headers(), timeout=10)
if response.status_code == 200:
return response.json(), 200
elif response.status_code == 404:
return {"error": "Job not found"}, 404
else:
- return {"error": f"Service returned {response.status_code}"}, response.status_code
+ return {"error": f"Service returned {response.status_code}"}, 502
except requests.exceptions.ConnectionError:
return {"error": "Cannot connect to AI Subtitle Translator service"}, 503
except Exception as e:
@@ -138,11 +155,11 @@ def get(self):
return {"error": "AI Subtitle Translator service URL not configured"}, 503
try:
- response = requests.get(f"{service_url}/api/v1/models", timeout=10)
+ response = requests.get(f"{service_url}/api/v1/models", headers=get_translator_auth_headers(), timeout=10)
if response.status_code == 200:
return response.json(), 200
else:
- return {"error": f"Service returned {response.status_code}"}, response.status_code
+ return {"error": f"Service returned {response.status_code}"}, 502
except requests.exceptions.ConnectionError:
return {"error": "Cannot connect to AI Subtitle Translator service"}, 503
except requests.exceptions.Timeout:
@@ -165,13 +182,70 @@ def get(self):
return {"error": "AI Subtitle Translator service URL not configured"}, 503
try:
- response = requests.get(f"{service_url}/api/v1/config", timeout=10)
+ response = requests.get(f"{service_url}/api/v1/config", headers=get_translator_auth_headers(), timeout=10)
if response.status_code == 200:
return response.json(), 200
else:
- return {"error": f"Service returned {response.status_code}"}, response.status_code
+ return {"error": f"Service returned {response.status_code}"}, 502
except requests.exceptions.ConnectionError:
return {"error": "Cannot connect to AI Subtitle Translator service"}, 503
except Exception as e:
logger.error(f"Error getting config: {e}")
+ return {"error": str(e)}, 500
+
+
+@api_ns_translator.route('translator/test')
+class TranslatorTest(Resource):
+ @authenticate
+ @api_ns_translator.doc(
+ responses={200: 'Success', 400: 'Bad Request', 503: 'Service Unavailable'}
+ )
+ def post(self):
+ """Test connection, encryption, and API key with the translator service.
+
+ Accepts optional JSON body with current (unsaved) form values:
+ - serviceUrl: override saved service URL
+ - apiKey: override saved OpenRouter API key
+ - encryptionKey: override saved encryption key
+ """
+ data = flask_request.get_json(silent=True) or {}
+
+ service_url = data.get("serviceUrl") or get_service_url()
+ if service_url:
+ service_url = service_url.rstrip("/")
+ if not service_url:
+ return {"error": "AI Subtitle Translator service URL not configured"}, 503
+
+ api_key = data.get("apiKey") or settings.translator.openrouter_api_key
+ if not api_key:
+ return {"error": "OpenRouter API key not configured"}, 400
+
+ encryption_key = data.get("encryptionKey") if "encryptionKey" in data else settings.translator.openrouter_encryption_key
+ if encryption_key:
+ try:
+ from subtitles.tools.translate.services.encryption import encrypt_api_key
+ api_key = encrypt_api_key(api_key, encryption_key)
+ except ValueError as e:
+ return {"error": f"Invalid encryption key: {e}"}, 400
+
+ try:
+ response = requests.post(
+ f"{service_url}/api/v1/test",
+ json={"apiKey": api_key},
+ headers={"Content-Type": "application/json", **get_translator_auth_headers(encryption_key)},
+ timeout=10
+ )
+ if response.status_code == 200:
+ return response.json(), 200
+ else:
+ try:
+ return response.json(), 502
+ except (ValueError, requests.exceptions.JSONDecodeError):
+ return {"error": f"Service returned {response.status_code}"}, 502
+ except requests.exceptions.ConnectionError:
+ return {"error": "Cannot connect to AI Subtitle Translator service"}, 503
+ except requests.exceptions.Timeout:
+ return {"error": "Service timeout"}, 503
+ except Exception as e:
+ logger.error(f"Error testing translator: {e}")
return {"error": str(e)}, 500
\ No newline at end of file
diff --git a/bazarr/api/utils.py b/bazarr/api/utils.py
index c8dac68bb4..8341c9b7ed 100644
--- a/bazarr/api/utils.py
+++ b/bazarr/api/utils.py
@@ -1,6 +1,8 @@
# coding=utf-8
import ast
+import hmac
+import logging
from functools import wraps
from flask import request, abort
@@ -16,17 +18,32 @@
False_Keys = ['False', 'false', '0']
+def _safe_apikey_compare(provided, expected):
+ if not provided:
+ return False
+ return hmac.compare_digest(str(provided), str(expected))
+
+
def authenticate(actual_method):
@wraps(actual_method)
def wrapper(*args, **kwargs):
apikey_settings = settings.auth.apikey
+ apikey_header = request.headers.get('X-API-KEY')
+
+ if _safe_apikey_compare(apikey_header, apikey_settings):
+ return actual_method(*args, **kwargs)
+
+ # Legacy: accept API key from query string or form data with deprecation warning
+ # Suppress warning for webhook endpoints (Plex webhooks use ?apikey= in callback URLs)
apikey_get = request.args.get('apikey')
apikey_post = request.form.get('apikey')
- apikey_header = None
- if 'X-API-KEY' in request.headers:
- apikey_header = request.headers['X-API-KEY']
-
- if apikey_settings in [apikey_get, apikey_post, apikey_header]:
+ if _safe_apikey_compare(apikey_get, apikey_settings) or _safe_apikey_compare(apikey_post, apikey_settings):
+ if '/webhooks/' not in request.path:
+ logging.warning(
+ 'API key passed via query string or form data is deprecated. '
+ 'Use the X-API-KEY header instead. '
+ 'Endpoint: %s %s', request.method, request.path
+ )
return actual_method(*args, **kwargs)
return abort(401)
diff --git a/bazarr/app/announcements.py b/bazarr/app/announcements.py
index 753399dfa7..831f7d6da8 100644
--- a/bazarr/app/announcements.py
+++ b/bazarr/app/announcements.py
@@ -1,7 +1,6 @@
# coding=utf-8
import os
-import sys
import hashlib
import requests
import logging
@@ -11,21 +10,12 @@
from datetime import datetime
from operator import itemgetter
-from app.get_providers import get_enabled_providers
from app.database import TableAnnouncements, database, insert, select
-from app.config import settings
from app.get_args import args
-from sonarr.info import get_sonarr_info
-from radarr.info import get_radarr_info
from app.jobs_queue import jobs_queue
-def upcoming_deprecated_python_version():
- # return True if Python version is deprecated
- return sys.version_info.major == 2 or (sys.version_info.major == 3 and sys.version_info.minor < 10)
-
-
# Announcements as receive by browser must be in the form of a list of dicts converted to JSON
# [
# {
@@ -49,32 +39,33 @@ def parse_announcement_dict(announcement_dict):
return announcement_dict
-def get_announcements_to_file(job_id=None, startup=False):
+def get_announcements_to_file(job_id=None, startup=False, wait_for_completion=False):
if not startup and not job_id:
- jobs_queue.add_job_from_function("Updating Announcements File", is_progress=False)
+ jobs_queue.add_job_from_function("Updating Announcements File", is_progress=False,
+ wait_for_completion=wait_for_completion)
return
try:
r = requests.get(
- url="https://cdn.jsdelivr.net/gh/morpheus65535/bazarr-binaries@latest/announcements.json",
+ url="https://cdn.jsdelivr.net/gh/LavX/bazarr-binaries@master/announcements.json",
timeout=30
)
+ r.raise_for_status()
except Exception:
try:
logging.exception("Error trying to get announcements from jsdelivr.net, falling back to Github.")
r = requests.get(
- url="https://raw.githubusercontent.com/morpheus65535/bazarr-binaries/refs/heads/master/announcements.json",
+ url="https://raw.githubusercontent.com/LavX/bazarr-binaries/refs/heads/master/announcements.json",
timeout=30
)
+ r.raise_for_status()
except Exception:
logging.exception("Error trying to get announcements from Github.")
return
- else:
- with open(os.path.join(args.config_dir, 'config', 'announcements.json'), 'wb') as f:
- f.write(r.content)
- finally:
- if not startup:
- jobs_queue.update_job_name(job_id=job_id, new_job_name="Updated Announcements File")
+ with open(os.path.join(args.config_dir, 'config', 'announcements.json'), 'wb') as f:
+ f.write(r.content)
+ if not startup:
+ jobs_queue.update_job_name(job_id=job_id, new_job_name="Updated Announcements File")
def get_online_announcements():
@@ -94,65 +85,7 @@ def get_online_announcements():
def get_local_announcements():
- announcements = []
-
- # opensubtitles.org end-of-life
- enabled_providers = get_enabled_providers()
- if enabled_providers and 'opensubtitles' in enabled_providers and not settings.opensubtitles.vip:
- announcements.append({
- 'text': 'Opensubtitles.org is deprecated for non-VIP users, migrate to Opensubtitles.com ASAP and disable '
- 'this provider to remove this announcement.',
- 'link': 'https://wiki.bazarr.media/Troubleshooting/OpenSubtitles-migration/',
- 'dismissible': True,
- 'timestamp': 1676236978,
- })
-
- # opensubtitles-scraper alternative announcement
- if enabled_providers and 'opensubtitles' in enabled_providers:
- announcements.append({
- 'text': 'An alternative OpenSubtitles scraper service is available that bypasses API limitations. '
- 'Consider using opensubtitles-scraper for improved subtitle downloads.',
- 'link': 'https://github.com/morpheus65535/bazarr/tree/master/opensubtitles-scraper',
- 'dismissible': True,
- 'timestamp': 1765805148,
- })
-
-
- # deprecated Sonarr and Radarr versions
- if get_sonarr_info.is_deprecated():
- announcements.append({
- 'text': f'Sonarr {get_sonarr_info.version()} is deprecated and unsupported. You should consider upgrading '
- f'as Bazarr will eventually drop support for deprecated Sonarr version.',
- 'link': 'https://forums.sonarr.tv/t/v3-is-now-officially-stable-v2-is-eol/27858',
- 'dismissible': False,
- 'timestamp': 1679606061,
- })
- if get_radarr_info.is_deprecated():
- announcements.append({
- 'text': f'Radarr {get_radarr_info.version()} is deprecated and unsupported. You should consider upgrading '
- f'as Bazarr will eventually drop support for deprecated Radarr version.',
- 'link': 'https://discord.com/channels/264387956343570434/264388019585286144/1051567458697363547',
- 'dismissible': False,
- 'timestamp': 1679606309,
- })
-
- # upcoming deprecated Python versions
- if upcoming_deprecated_python_version():
- announcements.append({
- 'text': 'Starting with Bazarr 1.6, support for Python 3.8 and 3.9 will get dropped. Upgrade your current '
- 'version of Python ASAP to get further updates.',
- 'link': 'https://wiki.bazarr.media/Troubleshooting/Windows_installer_reinstall/',
- 'dismissible': False,
- 'timestamp': 1744469706,
- })
-
- for announcement in announcements:
- if 'enabled' not in announcement:
- announcement['enabled'] = True
- if 'dismissible' not in announcement:
- announcement['dismissible'] = True
-
- return announcements
+ return []
def get_all_announcements():
diff --git a/bazarr/app/check_update.py b/bazarr/app/check_update.py
index 3a6d4beadc..c02872bb71 100644
--- a/bazarr/app/check_update.py
+++ b/bazarr/app/check_update.py
@@ -17,63 +17,103 @@
# Fork configuration - allows overriding via environment variable
# Default: LavX/bazarr (this fork)
-# To use upstream releases, set BAZARR_UPSTREAM_REPO=morpheus65535/Bazarr
+# To use upstream releases, set BAZARR_RELEASES_REPO to the upstream repo
RELEASES_REPO = os.environ.get('BAZARR_RELEASES_REPO', 'LavX/bazarr')
+# Microservice repositories to include in releases page
+MICROSERVICE_REPOS = [
+ ('LavX/opensubtitles-scraper', 'OpenSubtitles Scraper'),
+ ('LavX/ai-subtitle-translator', 'AI Subtitle Translator'),
+]
+
def deprecated_python_version():
# return True if Python version is deprecated
- return sys.version_info.major == 2 or (sys.version_info.major == 3 and sys.version_info.minor < 8)
-
+ return sys.version_info.major == 2 or (sys.version_info.major == 3 and sys.version_info.minor < 10)
-def check_releases(job_id=None, startup=False):
- # startup is used to prevent trying to create a job before the jobs queue is initialized
- if not startup and not job_id:
- jobs_queue.add_job_from_function("Updating Release Info", is_progress=False)
- return
+def _fetch_repo_releases(repo, label=None):
+ """Fetch releases from a single GitHub repo. Returns list of release dicts."""
releases = []
- url_releases = f'https://api.github.com/repos/{RELEASES_REPO}/releases?per_page=100'
+ url = f'https://api.github.com/repos/{repo}/releases?per_page=100'
try:
- logging.debug(f'BAZARR getting releases from Github: {url_releases}')
- r = requests.get(url_releases, allow_redirects=True, timeout=15)
+ logging.debug(f'BAZARR getting releases from Github: {url}')
+ r = requests.get(url, allow_redirects=True, timeout=15)
r.raise_for_status()
except requests.exceptions.HTTPError:
- logging.exception("Error trying to get releases from Github. Http error.")
+ logging.exception(f"Error trying to get releases from Github ({repo}). Http error.")
except requests.exceptions.ConnectionError:
- logging.exception("Error trying to get releases from Github. Connection Error.")
+ logging.exception(f"Error trying to get releases from Github ({repo}). Connection Error.")
except requests.exceptions.Timeout:
- logging.exception("Error trying to get releases from Github. Timeout Error.")
+ logging.exception(f"Error trying to get releases from Github ({repo}). Timeout Error.")
except requests.exceptions.RequestException:
- logging.exception("Error trying to get releases from Github.")
+ logging.exception(f"Error trying to get releases from Github ({repo}).")
else:
- for release in r.json():
+ try:
+ releases_data = r.json()
+ except ValueError:
+ logging.error(f"Error parsing JSON from Github releases response ({repo}). Skipping.")
+ return releases
+ for release in releases_data:
download_link = None
- for asset in release['assets']:
- if asset['name'] == 'bazarr.zip':
- download_link = asset['browser_download_url']
- if not download_link:
- continue
- releases.append({'name': release['name'],
- 'body': release['body'],
- 'date': release['published_at'],
- 'prerelease': release['prerelease'],
- 'download_link': download_link})
- with open(os.path.join(args.config_dir, 'config', 'releases.txt'), 'w') as f:
- json.dump(releases, f)
- logging.debug(f'BAZARR saved {len(r.json())} releases to releases.txt')
- finally:
- if not startup:
- jobs_queue.update_job_name(job_id=job_id, new_job_name="Updated Release Info")
-
-
-def check_if_new_update():
+ for asset in release.get('assets', []):
+ download_link = asset['browser_download_url']
+ break
+ entry = {'name': release['name'],
+ 'body': release['body'] or '',
+ 'date': release['published_at'],
+ 'prerelease': release['prerelease'],
+ 'download_link': download_link}
+ if label:
+ entry['repo'] = label
+ releases.append(entry)
+ logging.debug(f'BAZARR fetched {len(releases)} releases from {repo}')
+ return releases
+
+
+def check_releases(job_id=None, startup=False, wait_for_completion=False):
+ # startup is used to prevent trying to create a job before the jobs queue is initialized
+ if not startup and not job_id:
+ jobs_queue.add_job_from_function("Updating Release Info", is_progress=False,
+ wait_for_completion=wait_for_completion)
+ return
+
+ releases = _fetch_repo_releases(RELEASES_REPO, label='Bazarr+')
+
+ for repo, label in MICROSERVICE_REPOS:
+ releases.extend(_fetch_repo_releases(repo, label=label))
+
+ releases.sort(key=lambda r: r['date'], reverse=True)
+
+ with open(os.path.join(args.config_dir, 'config', 'releases.txt'), 'w') as f:
+ json.dump(releases, f)
+ logging.debug(f'BAZARR saved {len(releases)} releases to releases.txt')
+
+ if job_id:
+ jobs_queue.update_job_name(job_id=job_id, new_job_name="Updated Release Info")
+
+
+def check_if_new_update(startup=False, job_id=None, wait_for_completion=False):
+ if not startup and not job_id:
+ jobs_queue.add_job_from_function("Checking for Bazarr update", is_progress=False,
+ wait_for_completion=wait_for_completion)
+ return
+
+ # Skip auto-update when running from source (no BAZARR_VERSION set)
+ bazarr_version = os.environ.get("BAZARR_VERSION", "")
+ if not bazarr_version:
+ logging.debug('BAZARR running from source, skipping auto-update')
+ check_releases(startup=True)
+ return
+
if settings.general.branch == 'master':
use_prerelease = False
elif settings.general.branch == 'development':
use_prerelease = True
else:
logging.error(f'BAZARR unknown branch provided to updater: {settings.general.branch}')
+ if job_id:
+ jobs_queue.update_job_name(job_id=job_id, new_job_name="Failed to check for Bazarr update")
return
logging.debug(f'BAZARR updater is using {settings.general.branch} branch')
@@ -128,9 +168,14 @@ def check_if_new_update():
logging.debug('BAZARR no release found')
else:
logging.debug('BAZARR --no_update have been used as an argument')
+ if job_id:
+ jobs_queue.update_job_name(job_id=job_id, new_job_name="Checked for Bazarr update")
def download_release(url):
+ if not url:
+ logging.debug('BAZARR release has no download URL, skipping update')
+ return
r = None
update_dir = os.path.join(args.config_dir, 'update')
try:
@@ -139,7 +184,7 @@ def download_release(url):
logging.debug(f'BAZARR unable to create update directory {update_dir}')
else:
logging.debug(f'BAZARR downloading release from Github: {url}')
- r = requests.get(url, allow_redirects=True)
+ r = requests.get(url, allow_redirects=True, timeout=300)
if r:
try:
with open(os.path.join(update_dir, 'bazarr.zip'), 'wb') as f:
diff --git a/bazarr/app/config.py b/bazarr/app/config.py
index 74acc0cae9..941c4b35cd 100644
--- a/bazarr/app/config.py
+++ b/bazarr/app/config.py
@@ -89,7 +89,7 @@ def check_parser_binary(value):
Validator('general.port', must_exist=True, default=6767, is_type_of=int, gte=1, lte=65535),
Validator('general.hostname', must_exist=True, default=platform.node(), is_type_of=str),
Validator('general.base_url', must_exist=True, default='', is_type_of=str),
- Validator('general.instance_name', must_exist=True, default='Bazarr', is_type_of=str,
+ Validator('general.instance_name', must_exist=True, default='Bazarr+', is_type_of=str,
apply_default_on_none=True),
Validator('general.path_mappings', must_exist=True, default=[], is_type_of=list),
Validator('general.debug', must_exist=True, default=False, is_type_of=bool),
@@ -97,7 +97,7 @@ def check_parser_binary(value):
is_in=['master', 'development']),
Validator('general.auto_update', must_exist=True, default=True, is_type_of=bool),
Validator('general.single_language', must_exist=True, default=False, is_type_of=bool),
- Validator('general.minimum_score', must_exist=True, default=90, is_type_of=int, gte=0, lte=100),
+ Validator('general.minimum_score', must_exist=True, default=80, is_type_of=int, gte=0, lte=100),
Validator('general.use_scenename', must_exist=True, default=True, is_type_of=bool),
Validator('general.use_postprocessing', must_exist=True, default=False, is_type_of=bool),
Validator('general.postprocessing_cmd', must_exist=True, default='', is_type_of=str),
@@ -140,6 +140,7 @@ def check_parser_binary(value):
is_in=['3d', '1w', '2w', '3w', '4w']),
Validator('general.enabled_providers', must_exist=True, default=[], is_type_of=list),
Validator('general.provider_priorities', must_exist=True, default={}, is_type_of=dict),
+ Validator('general.use_provider_priority', must_exist=True, default=True, is_type_of=bool),
Validator('general.enabled_integrations', must_exist=True, default=[], is_type_of=list),
Validator('general.multithreading', must_exist=True, default=True, is_type_of=bool),
Validator('general.chmod_enabled', must_exist=True, default=False, is_type_of=bool),
@@ -198,8 +199,9 @@ def check_parser_binary(value):
# translating section
Validator('translator.default_score', must_exist=True, default=50, is_type_of=int, gte=0),
- Validator('translator.gemini_key', must_exist=True, default='', is_type_of=str, cast=str),
+ Validator('translator.gemini_keys', must_exist=True, default=[], is_type_of=list),
Validator('translator.gemini_model', must_exist=True, default='gemini-2.0-flash', is_type_of=str, cast=str),
+ Validator('translator.gemini_batch_size', must_exist=True, default=300, is_type_of=int, gte=1),
Validator('translator.translator_info', must_exist=True, default=True, is_type_of=bool),
Validator('translator.translator_type', must_exist=True, default='google_translate', is_type_of=str, cast=str),
Validator('translator.lingarr_url', must_exist=True, default='http://lingarr:9876', is_type_of=str),
@@ -210,6 +212,8 @@ def check_parser_binary(value):
Validator('translator.openrouter_max_concurrent', must_exist=True, default=2, is_type_of=int, gte=1, lte=10),
Validator('translator.openrouter_reasoning', must_exist=True, default='disabled', is_type_of=str,
is_in=['disabled', 'low', 'medium', 'high']),
+ Validator('translator.openrouter_parallel_batches', must_exist=True, default=4, is_type_of=int, gte=1, lte=8),
+ Validator('translator.openrouter_encryption_key', must_exist=True, default='', is_type_of=str, cast=str),
Validator('translator.lingarr_token', must_exist=True, default='', is_type_of=str, cast=str),
# sonarr section
@@ -235,6 +239,7 @@ def check_parser_binary(value):
Validator('sonarr.defer_search_signalr', must_exist=True, default=False, is_type_of=bool),
Validator('sonarr.sync_only_monitored_series', must_exist=True, default=False, is_type_of=bool),
Validator('sonarr.sync_only_monitored_episodes', must_exist=True, default=False, is_type_of=bool),
+ Validator('sonarr.verify_ssl', must_exist=True, default=False, is_type_of=bool),
# radarr section
Validator('radarr.ip', must_exist=True, default='127.0.0.1', is_type_of=str),
@@ -256,6 +261,7 @@ def check_parser_binary(value):
Validator('radarr.use_ffprobe_cache', must_exist=True, default=True, is_type_of=bool),
Validator('radarr.defer_search_signalr', must_exist=True, default=False, is_type_of=bool),
Validator('radarr.sync_only_monitored_movies', must_exist=True, default=False, is_type_of=bool),
+ Validator('radarr.verify_ssl', must_exist=True, default=False, is_type_of=bool),
# plex section
Validator('plex.ip', must_exist=True, default='127.0.0.1', is_type_of=str),
@@ -277,6 +283,7 @@ def check_parser_binary(value):
Validator('plex.user_id', must_exist=True, default='', is_type_of=(int, str)),
Validator('plex.auth_method', must_exist=True, default='apikey', is_type_of=str, is_in=['apikey', 'oauth']),
Validator('plex.encryption_key', must_exist=True, default='', is_type_of=str),
+ Validator('plex.verify_ssl', must_exist=True, default=False, is_type_of=bool),
Validator('plex.server_machine_id', must_exist=True, default='', is_type_of=str),
Validator('plex.server_name', must_exist=True, default='', is_type_of=str),
Validator('plex.server_url', must_exist=True, default='', is_type_of=str),
@@ -305,9 +312,9 @@ def check_parser_binary(value):
Validator('opensubtitles.ssl', must_exist=True, default=False, is_type_of=bool),
Validator('opensubtitles.timeout', must_exist=True, default=15, is_type_of=int, gte=1),
Validator('opensubtitles.skip_wrong_fps', must_exist=True, default=False, is_type_of=bool),
- # Web scraper mode - can be enabled via OPENSUBTITLES_USE_WEB_SCRAPER=true environment variable
+ # Web scraper mode - always enabled (OpenSubtitles.org login no longer available)
Validator('opensubtitles.use_web_scraper', must_exist=True,
- default=os.environ.get('OPENSUBTITLES_USE_WEB_SCRAPER', 'false').lower() in ('true', '1', 'yes'),
+ default=True,
is_type_of=bool),
# Scraper URL - can be set via OPENSUBTITLES_SCRAPER_URL environment variable
Validator('opensubtitles.scraper_service_url', must_exist=True,
@@ -451,33 +458,6 @@ def check_parser_binary(value):
Validator('subsync.max_offset_seconds', must_exist=True, default=60, is_type_of=int,
is_in=[60, 120, 300, 600]),
- # series_scores section
- Validator('series_scores.hash', must_exist=True, default=359, is_type_of=int),
- Validator('series_scores.series', must_exist=True, default=180, is_type_of=int),
- Validator('series_scores.year', must_exist=True, default=90, is_type_of=int),
- Validator('series_scores.season', must_exist=True, default=30, is_type_of=int),
- Validator('series_scores.episode', must_exist=True, default=30, is_type_of=int),
- Validator('series_scores.release_group', must_exist=True, default=14, is_type_of=int),
- Validator('series_scores.source', must_exist=True, default=7, is_type_of=int),
- Validator('series_scores.audio_codec', must_exist=True, default=3, is_type_of=int),
- Validator('series_scores.resolution', must_exist=True, default=2, is_type_of=int),
- Validator('series_scores.video_codec', must_exist=True, default=2, is_type_of=int),
- Validator('series_scores.streaming_service', must_exist=True, default=1, is_type_of=int),
- Validator('series_scores.hearing_impaired', must_exist=True, default=1, is_type_of=int),
-
- # movie_scores section
- Validator('movie_scores.hash', must_exist=True, default=119, is_type_of=int),
- Validator('movie_scores.title', must_exist=True, default=60, is_type_of=int),
- Validator('movie_scores.year', must_exist=True, default=30, is_type_of=int),
- Validator('movie_scores.release_group', must_exist=True, default=13, is_type_of=int),
- Validator('movie_scores.source', must_exist=True, default=7, is_type_of=int),
- Validator('movie_scores.audio_codec', must_exist=True, default=3, is_type_of=int),
- Validator('movie_scores.resolution', must_exist=True, default=2, is_type_of=int),
- Validator('movie_scores.video_codec', must_exist=True, default=2, is_type_of=int),
- Validator('movie_scores.streaming_service', must_exist=True, default=1, is_type_of=int),
- Validator('movie_scores.edition', must_exist=True, default=1, is_type_of=int),
- Validator('movie_scores.hearing_impaired', must_exist=True, default=1, is_type_of=int),
-
# postgresql section
Validator('postgresql.enabled', must_exist=True, default=False, is_type_of=bool),
Validator('postgresql.host', must_exist=True, default='localhost', is_type_of=str),
@@ -589,6 +569,7 @@ def write_config():
'excluded_series_types',
'enabled_providers',
'enabled_integrations',
+ 'gemini_keys',
'path_mappings',
'path_mappings_movie',
'remove_profile_tags',
@@ -627,6 +608,20 @@ def write_config():
settings.embeddedsubtitles.unknown_as_fallback = True
settings.embeddedsubtitles.fallback_lang = 'en'
del settings.embeddedsubtitles.unknown_as_english
+
+# delete custom scores sections since we don't use this anymore
+if hasattr(settings, 'series_scores'):
+ settings.unset('SERIES_SCORES')
+if hasattr(settings, 'movie_scores'):
+ settings.unset('MOVIE_SCORES')
+
+# backward compatibility: migrate gemini_key to gemini_keys
+if hasattr(settings.translator, 'gemini_key'):
+ legacy_key = str(settings.translator.gemini_key).strip()
+ if legacy_key and not settings.translator.gemini_keys:
+ settings.translator.gemini_keys = [legacy_key]
+ del settings.translator.gemini_key
+
# save updated settings to file
write_config()
@@ -704,12 +699,15 @@ def save_settings(settings_items):
if value in empty_values and value != '':
value = None
- # try to cast string as integer
+ # try to cast string as integer or float
if isinstance(value, str) and settings_keys[-1] not in str_keys:
try:
value = int(value)
except ValueError:
- pass
+ try:
+ value = float(value)
+ except ValueError:
+ pass
# Make sure empty language list are stored correctly
if settings_keys[-1] in array_keys and value[0] in empty_values:
@@ -752,7 +750,8 @@ def save_settings(settings_items):
if key == 'settings-auth-password':
if value != settings.auth.password and value is not None:
- value = hashlib.md5(f"{value}".encode('utf-8')).hexdigest()
+ from utilities.helper import hash_password
+ value = hash_password(value)
if key == 'settings-general-debug':
configure_debug = True
@@ -1017,9 +1016,15 @@ def configure_proxy_func():
os.environ['NO_PROXY'] = exclude
-def get_scores():
- settings = get_settings()
- return {"movie": settings["movie_scores"], "episode": settings["series_scores"]}
+_SSL_VERIFY_SERVICES = frozenset({'sonarr', 'radarr', 'plex'})
+
+
+def get_ssl_verify(service):
+ """Return the verify parameter for requests calls to a service."""
+ if service not in _SSL_VERIFY_SERVICES:
+ raise ValueError(f"Unknown service for SSL verify: {service}")
+ return settings.get(f'{service}.verify_ssl', False)
+
def sync_checker(subtitle):
diff --git a/bazarr/app/database.py b/bazarr/app/database.py
index 8504d3342c..79cad39fb0 100644
--- a/bazarr/app/database.py
+++ b/bazarr/app/database.py
@@ -87,6 +87,8 @@
def set_sqlite_pragma(dbapi_connection, connection_record):
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
+ cursor.execute("PRAGMA journal_mode=WAL")
+ cursor.execute("PRAGMA busy_timeout=5000")
cursor.close()
session_factory = sessionmaker(bind=engine)
@@ -188,6 +190,7 @@ class TableHistory(Base):
language = mapped_column(Text)
provider = mapped_column(Text)
score = mapped_column(Integer)
+ score_out_of = mapped_column(Integer, nullable=True)
sonarrEpisodeId = mapped_column(Integer, ForeignKey('table_episodes.sonarrEpisodeId', ondelete='CASCADE'))
sonarrSeriesId = mapped_column(Integer, ForeignKey('table_shows.sonarrSeriesId', ondelete='CASCADE'))
subs_id = mapped_column(Text)
@@ -209,6 +212,7 @@ class TableHistoryMovie(Base):
provider = mapped_column(Text)
radarrId = mapped_column(Integer, ForeignKey('table_movies.radarrId', ondelete='CASCADE'))
score = mapped_column(Integer)
+ score_out_of = mapped_column(Integer, nullable=True)
subs_id = mapped_column(Text)
subtitles_path = mapped_column(Text)
timestamp = mapped_column(DateTime, nullable=False, default=datetime.now)
diff --git a/bazarr/app/get_providers.py b/bazarr/app/get_providers.py
index 6d7739b1e0..7fed65069f 100644
--- a/bazarr/app/get_providers.py
+++ b/bazarr/app/get_providers.py
@@ -1,8 +1,8 @@
# coding=utf-8
import os
+import json
import datetime
-import pytz
import logging
import subliminal_patch
import pretty
@@ -12,6 +12,7 @@
import traceback
import re
+from zoneinfo import ZoneInfo
from requests import ConnectionError
from subzero.language import Language
from subliminal_patch.exceptions import (TooManyRequests, APIThrottled, ParseResponseError, IPAddressBlocked,
@@ -31,8 +32,7 @@
_TRACEBACK_RE = re.compile(r'File "(.*?providers[\\/].*?)", line (\d+)')
-def time_until_midnight(timezone):
- # type: (datetime.datetime) -> datetime.timedelta
+def time_until_midnight(timezone) -> datetime.timedelta:
"""
Get timedelta until midnight.
"""
@@ -45,13 +45,13 @@ def time_until_midnight(timezone):
# Titulky resets its download limits at the start of a new day from its perspective - the Europe/Prague timezone
# Needs to convert to offset-naive dt
def titulky_limit_reset_timedelta():
- return time_until_midnight(timezone=pytz.timezone('Europe/Prague'))
+ return time_until_midnight(timezone=ZoneInfo('Europe/Prague'))
# LegendasDivx reset its searches limit at approximately midnight, Lisbon time, every day. We wait 1 more hours just
# to be sure.
def legendasdivx_limit_reset_timedelta():
- return time_until_midnight(timezone=pytz.timezone('Europe/Lisbon')) + datetime.timedelta(minutes=60)
+ return time_until_midnight(timezone=ZoneInfo('Europe/Lisbon')) + datetime.timedelta(minutes=60)
VALID_THROTTLE_EXCEPTIONS = (TooManyRequests, DownloadLimitExceeded, ServiceUnavailable, APIThrottled,
@@ -202,7 +202,7 @@ def get_providers():
else:
logging.info("Using %s again after %s, (disabled because: %s)", provider, throttle_desc, reason)
del tp[provider]
- set_throttled_providers(str(tp))
+ set_throttled_providers(tp)
# if forced only is enabled: # fixme: Prepared for forced only implementation to remove providers with don't support forced only subtitles
# for provider in providers_list:
# if provider in PROVIDERS_FORCED_OFF:
@@ -397,7 +397,7 @@ def _handle_mgb(name, exception, ids, language):
if ids:
if exception.media_type == "series":
- if 'sonarrSeriesId' in ids and 'sonarrEpsiodeId' in ids:
+ if 'sonarrSeriesId' in ids and 'sonarrEpisodeId' in ids:
blacklist_log(ids['sonarrSeriesId'], ids['sonarrEpisodeId'], name, exception.id, language_str)
else:
blacklist_log_movie(ids['radarrId'], name, exception.id, language_str)
@@ -411,7 +411,7 @@ def provider_throttle(name, exception, ids=None, language=None):
cls_name = getattr(cls, "__name__")
if cls not in VALID_THROTTLE_EXCEPTIONS:
for valid_cls in VALID_THROTTLE_EXCEPTIONS:
- if isinstance(cls, valid_cls):
+ if issubclass(cls, valid_cls):
cls = valid_cls
throttle_data = provider_throttle_map().get(name, provider_throttle_map()["default"]).get(cls, None) or \
@@ -424,7 +424,7 @@ def provider_throttle(name, exception, ids=None, language=None):
throttle_until = datetime.datetime.now() + throttle_delta
- if cls_name not in VALID_COUNT_EXCEPTIONS or throttled_count(name):
+ if cls_name not in VALID_COUNT_EXCEPTIONS or throttled_count(name, exception):
if cls_name == 'ValueError' and isinstance(exception.args, tuple) and len(exception.args) and exception.args[
0].startswith('unsupported pickle protocol'):
for fn in subliminal_cache_region.backend.all_filenames:
@@ -434,7 +434,7 @@ def provider_throttle(name, exception, ids=None, language=None):
logging.debug("Couldn't remove cache file: %s", os.path.basename(fn))
else:
tp[name] = (cls_name, throttle_until, throttle_description)
- set_throttled_providers(str(tp))
+ set_throttled_providers(tp)
trac_info = _get_traceback_info(exception)
@@ -465,7 +465,7 @@ def _get_traceback_info(exc: Exception):
return message + extra
-def throttled_count(name):
+def throttled_count(name, exception=None):
global throttle_count
if name in list(throttle_count.keys()):
if 'count' in list(throttle_count[name].keys()):
@@ -483,9 +483,15 @@ def throttled_count(name):
return True
if throttle_count[name]['time'] <= datetime.datetime.now():
throttle_count[name] = {"count": 1, "time": (datetime.datetime.now() + datetime.timedelta(seconds=120))}
- logging.info("Provider %s throttle count %s of 5, waiting 5sec and trying again", name,
- throttle_count[name]['count'])
- time.sleep(5)
+
+ # Respect Retry-After from the exception if available, otherwise default to 5s
+ wait_seconds = 5
+ if exception and hasattr(exception, 'retry_after') and exception.retry_after:
+ wait_seconds = max(1, min(exception.retry_after, 30)) # floor at 1s, cap at 30s
+
+ logging.info("Provider %s throttle count %s of 5, waiting %ds and trying again", name,
+ throttle_count[name]['count'], wait_seconds)
+ time.sleep(wait_seconds)
return False
@@ -496,7 +502,7 @@ def update_throttled_provider():
for provider in list(tp):
if provider not in providers_list:
del tp[provider]
- set_throttled_providers(str(tp))
+ set_throttled_providers(tp)
reason, until, throttle_desc = tp.get(provider, (None, None, None))
@@ -507,7 +513,7 @@ def update_throttled_provider():
else:
logging.info("Using %s again after %s, (disabled because: %s)", provider, throttle_desc, reason)
del tp[provider]
- set_throttled_providers(str(tp))
+ set_throttled_providers(tp)
reason, until, throttle_desc = tp.get(provider, (None, None, None))
@@ -516,7 +522,7 @@ def update_throttled_provider():
if now >= until:
logging.info("Using %s again after %s, (disabled because: %s)", provider, throttle_desc, reason)
del tp[provider]
- set_throttled_providers(str(tp))
+ set_throttled_providers(tp)
event_stream(type='badges')
@@ -538,7 +544,7 @@ def reset_throttled_providers(only_auth_or_conf_error=False):
'PaymentRequired']:
continue
del tp[provider]
- set_throttled_providers(str(tp))
+ set_throttled_providers(tp)
update_throttled_provider()
if only_auth_or_conf_error:
logging.info('BAZARR throttled providers have been reset (only AuthenticationError, ConfigurationError and '
@@ -547,24 +553,50 @@ def reset_throttled_providers(only_auth_or_conf_error=False):
logging.info('BAZARR throttled providers have been reset.')
+def _throttled_providers_path():
+ return os.path.normpath(os.path.join(args.config_dir, 'config', 'throttled_providers.dat'))
+
+
def get_throttled_providers():
providers = {}
+ dat_path = _throttled_providers_path()
try:
- if os.path.exists(os.path.join(args.config_dir, 'config', 'throttled_providers.dat')):
- with open(os.path.normpath(os.path.join(args.config_dir, 'config', 'throttled_providers.dat')), 'r') as \
- handle:
- providers = eval(handle.read())
+ if os.path.exists(dat_path):
+ with open(dat_path, 'r') as handle:
+ content = handle.read()
+ if not content.strip():
+ return providers
+ raw = json.loads(content)
+ for name, val in raw.items():
+ cls_name, until_str, description = val
+ until = datetime.datetime.fromisoformat(until_str) if until_str else None
+ providers[name] = (cls_name, until, description)
+ except (json.JSONDecodeError, KeyError, ValueError):
+ logging.info("Migrating throttled_providers.dat from legacy format to JSON. Throttle state reset.")
+ set_throttled_providers(providers)
except Exception:
- # set empty content in throttled_providers.dat
- logging.error("Invalid content in throttled_providers.dat. Resetting")
- set_throttled_providers(str(providers))
- finally:
- return providers
+ logging.exception("Unexpected error reading throttled_providers.dat. Resetting.")
+ set_throttled_providers(providers)
+ return providers
def set_throttled_providers(data):
- with open(os.path.normpath(os.path.join(args.config_dir, 'config', 'throttled_providers.dat')), 'w+') as handle:
- handle.write(data)
+ if not isinstance(data, dict):
+ raise TypeError(f"set_throttled_providers expects a dict, got {type(data).__name__}")
+ dat_path = _throttled_providers_path()
+ serializable = {}
+ for name, val in data.items():
+ cls_name, throttle_until, description = val
+ serializable[name] = (
+ cls_name,
+ throttle_until.isoformat() if throttle_until else None,
+ description
+ )
+ json_data = json.dumps(serializable)
+ tmp_path = dat_path + '.tmp'
+ with open(tmp_path, 'w') as handle:
+ handle.write(json_data)
+ os.replace(tmp_path, dat_path)
tp = get_throttled_providers()
diff --git a/bazarr/app/jobs_queue.py b/bazarr/app/jobs_queue.py
index ae613b4cac..362c8edece 100644
--- a/bazarr/app/jobs_queue.py
+++ b/bazarr/app/jobs_queue.py
@@ -4,6 +4,7 @@
import importlib
import inspect
import os
+import time
from time import sleep
from datetime import datetime
@@ -233,6 +234,14 @@ def update_job_name(self, job_id: int, new_job_name: str) -> bool:
return True
return False
+ def get_job_name(self, job_id: int) -> str:
+ """Get the current name of a job by its ID."""
+ queues = self.jobs_pending_queue + self.jobs_running_queue + self.jobs_failed_queue + self.jobs_completed_queue
+ for job in queues:
+ if job.job_id == job_id:
+ return job.job_name
+ return ""
+
def get_job_returned_value(self, job_id: int):
"""
Fetches the returned value of a job from the queue provided its unique identifier.
@@ -335,7 +344,8 @@ def update_job_progress_status(self, job_id: int, is_progress: bool = False) ->
return True
return False
- def add_job_from_function(self, job_name: str, is_progress: bool, progress_max: int = 0) -> int:
+ def add_job_from_function(self, job_name: str, is_progress: bool, progress_max: int = 0,
+ wait_for_completion: bool = False) -> int:
"""
Adds a job to the pending queue using the details of the calling function. The job is then executed.
@@ -345,6 +355,8 @@ def add_job_from_function(self, job_name: str, is_progress: bool, progress_max:
:type is_progress: bool
:param progress_max: Maximum progress value for the job, default is 0.
:type progress_max: int
+ :param wait_for_completion: Flag indicating whether to wait for the job to complete before returning.
+ :type wait_for_completion: bool
:return: ID of the added job.
:rtype: int
"""
@@ -380,6 +392,11 @@ def add_job_from_function(self, job_name: str, is_progress: bool, progress_max:
job_id = self.feed_jobs_pending_queue(job_name=job_name, module=parent_function_path, func=parent_function_name,
kwargs=arguments, is_progress=is_progress, progress_max=progress_max)
+ if wait_for_completion:
+ time.sleep(1)
+ while jobs_queue.get_job_status(job_id) in ['queued', 'running']:
+ time.sleep(1)
+
return job_id
def remove_job_from_pending_queue(self, job_id: int):
@@ -500,8 +517,21 @@ def consume_jobs_pending_queue(self):
try:
if self.jobs_pending_queue:
with self._queue_lock:
- can_run_job = (len(self.jobs_running_queue) < settings.general.concurrent_jobs
- and len(self.jobs_pending_queue) > 0)
+ next_job = self.jobs_pending_queue[0] if self.jobs_pending_queue else None
+ if next_job:
+ is_translation = 'translat' in (next_job.job_name or '').lower()
+ if is_translation:
+ # Translation jobs respect their own concurrency limit
+ running_translations = sum(
+ 1 for j in self.jobs_running_queue
+ if 'translat' in (j.job_name or '').lower()
+ )
+ max_translations = settings.translator.openrouter_max_concurrent
+ can_run_job = running_translations < max_translations
+ else:
+ can_run_job = len(self.jobs_running_queue) < settings.general.concurrent_jobs
+ else:
+ can_run_job = False
if can_run_job:
job_thread = Thread(target=self._run_job)
diff --git a/bazarr/app/logger.py b/bazarr/app/logger.py
index a5b998da1d..13422b18e0 100644
--- a/bazarr/app/logger.py
+++ b/bazarr/app/logger.py
@@ -9,7 +9,6 @@
from logging.handlers import TimedRotatingFileHandler
from utilities.central import get_log_file_path
-from pytz_deprecation_shim import PytzUsageWarning
from .config import settings
@@ -129,8 +128,6 @@ def filter(self, record):
def configure_logging(debug=False):
warnings.simplefilter('ignore', category=ResourceWarning)
- warnings.simplefilter('ignore', category=PytzUsageWarning)
- # warnings.simplefilter('ignore', category=SAWarning)
if debug:
log_level = logging.DEBUG
diff --git a/bazarr/app/notifier.py b/bazarr/app/notifier.py
index f248dfcce0..6988c187a6 100644
--- a/bazarr/app/notifier.py
+++ b/bazarr/app/notifier.py
@@ -37,7 +37,8 @@ def update_notifier():
database.execute(
insert(TableSettingsNotifier)
- .values(notifiers_added))
+ .values(notifiers_added)
+ .on_conflict_do_nothing())
def get_notifier_providers():
diff --git a/bazarr/app/scheduler.py b/bazarr/app/scheduler.py
index 7326e02e26..763cc6d4f3 100644
--- a/bazarr/app/scheduler.py
+++ b/bazarr/app/scheduler.py
@@ -25,6 +25,7 @@
from subtitles.indexer.series import series_full_scan_subtitles
from subtitles.wanted import wanted_search_missing_subtitles_series, wanted_search_missing_subtitles_movies
from subtitles.upgrade import upgrade_subtitles
+from subtitles.mass_operations import mass_batch_operation
from utilities.cache import cache_maintenance
from utilities.health import check_health
from utilities.backup import backup_to_zip
@@ -108,6 +109,7 @@ def update_configurable_tasks(self):
self.__update_bazarr_task()
self.__search_wanted_subtitles_task()
self.__upgrade_subtitles_task()
+ self.__mass_sync_task()
self.__randomize_interval_task()
self.__automatic_backup()
if args.no_tasks:
@@ -202,22 +204,24 @@ def __sonarr_update_task(self):
self.aps_scheduler.add_job(
update_series, 'interval', minutes=int(settings.sonarr.series_sync), max_instances=1,
coalesce=True, misfire_grace_time=15, id='update_series', name='Sync with Sonarr',
- replace_existing=True)
+ replace_existing=True, kwargs=dict(wait_for_completion=True))
def __radarr_update_task(self):
if settings.general.use_radarr:
self.aps_scheduler.add_job(
update_movies, 'interval', minutes=int(settings.radarr.movies_sync), max_instances=1,
coalesce=True, misfire_grace_time=15, id='update_movies', name='Sync with Radarr',
- replace_existing=True)
+ replace_existing=True, kwargs=dict(wait_for_completion=True))
def __cache_cleanup_task(self):
self.aps_scheduler.add_job(cache_maintenance, 'interval', hours=24, max_instances=1, coalesce=True,
- misfire_grace_time=15, id='cache_cleanup', name='Cache Maintenance')
+ misfire_grace_time=15, id='cache_cleanup', name='Cache Maintenance',
+ kwargs=dict(wait_for_completion=True))
def __check_health_task(self):
self.aps_scheduler.add_job(check_health, 'interval', hours=6, max_instances=1, coalesce=True,
- misfire_grace_time=15, id='check_health', name='Check Health')
+ misfire_grace_time=15, id='check_health', name='Check Health',
+ kwargs=dict(wait_for_completion=True))
def __automatic_backup(self):
backup = settings.backup.frequency
@@ -229,7 +233,8 @@ def __automatic_backup(self):
trigger = {'year': in_a_century()}
self.aps_scheduler.add_job(backup_to_zip, 'cron', **trigger,
max_instances=1, coalesce=True, misfire_grace_time=15, id='backup',
- name='Backup Database and Configuration File', replace_existing=True)
+ name='Backup Database and Configuration File', replace_existing=True,
+ kwargs=dict(wait_for_completion=True))
def __sonarr_full_update_task(self):
if settings.general.use_sonarr:
@@ -238,17 +243,18 @@ def __sonarr_full_update_task(self):
self.aps_scheduler.add_job(
series_full_scan_subtitles, 'cron', hour=settings.sonarr.full_update_hour, max_instances=1,
coalesce=True, misfire_grace_time=15, id='series_full_scan_subtitles',
- name='Index All Existing Episodes Subtitles', replace_existing=True)
+ name='Index All Existing Episodes Subtitles', replace_existing=True,
+ kwargs=dict(wait_for_completion=True))
elif full_update == "Weekly":
self.aps_scheduler.add_job(
series_full_scan_subtitles, 'cron', day_of_week=settings.sonarr.full_update_day,
hour=settings.sonarr.full_update_hour, max_instances=1, coalesce=True, misfire_grace_time=15,
id='series_full_scan_subtitles', name='Index All Existing Episodes Subtitles',
- replace_existing=True)
+ replace_existing=True, kwargs=dict(wait_for_completion=True))
elif full_update == "Manually":
self.aps_scheduler.add_job(
series_full_scan_subtitles, 'cron', year=in_a_century(), max_instances=1, coalesce=True,
- misfire_grace_time=15, id='series_full_scan_subtitles',
+ misfire_grace_time=15, id='series_full_scan_subtitles', kwargs=dict(wait_for_completion=True),
name='Index All Existing Episodes Subtitles', replace_existing=True)
def __radarr_full_update_task(self):
@@ -257,65 +263,78 @@ def __radarr_full_update_task(self):
if full_update == "Daily":
self.aps_scheduler.add_job(
movies_full_scan_subtitles, 'cron', hour=settings.radarr.full_update_hour, max_instances=1,
- coalesce=True, misfire_grace_time=15,
+ coalesce=True, misfire_grace_time=15, kwargs=dict(wait_for_completion=True),
id='movies_full_scan_subtitles', name='Index All Existing Movies Subtitles', replace_existing=True)
elif full_update == "Weekly":
self.aps_scheduler.add_job(
movies_full_scan_subtitles,
'cron', day_of_week=settings.radarr.full_update_day, hour=settings.radarr.full_update_hour,
max_instances=1, coalesce=True, misfire_grace_time=15, id='movies_full_scan_subtitles',
- name='Index All Existing Movies Subtitles', replace_existing=True)
+ name='Index All Existing Movies Subtitles', replace_existing=True,
+ kwargs=dict(wait_for_completion=True))
elif full_update == "Manually":
self.aps_scheduler.add_job(
movies_full_scan_subtitles, 'cron', year=in_a_century(), max_instances=1, coalesce=True,
misfire_grace_time=15, id='movies_full_scan_subtitles', name='Index All Existing Movies Subtitles',
- replace_existing=True)
+ replace_existing=True, kwargs=dict(wait_for_completion=True))
def __update_bazarr_task(self):
- if not args.no_update and os.environ["BAZARR_VERSION"] != '':
+ if not args.no_update and os.environ["BAZARR_VERSION"] not in ['', 'unknown']:
task_name = 'Update Bazarr'
if settings.general.auto_update:
self.aps_scheduler.add_job(
check_if_new_update, 'interval', hours=6, max_instances=1, coalesce=True,
- misfire_grace_time=15, id='update_bazarr', name=task_name, replace_existing=True)
+ misfire_grace_time=15, id='update_bazarr', name=task_name, replace_existing=True,
+ kwargs=dict(wait_for_completion=True))
else:
self.aps_scheduler.add_job(
check_if_new_update, 'cron', year=in_a_century(), hour=4, id='update_bazarr', name=task_name,
- replace_existing=True)
+ replace_existing=True, kwargs=dict(wait_for_completion=True))
self.aps_scheduler.add_job(
check_releases, 'interval', hours=3, max_instances=1, coalesce=True, misfire_grace_time=15,
- id='update_release', name='Update Release Info', replace_existing=True)
+ id='update_release', name='Update Release Info', replace_existing=True,
+ kwargs=dict(wait_for_completion=True))
else:
self.aps_scheduler.add_job(
check_releases, 'interval', hours=3, max_instances=1, coalesce=True, misfire_grace_time=15,
- id='update_release', name='Update Release Info', replace_existing=True)
+ id='update_release', name='Update Release Info', replace_existing=True,
+ kwargs=dict(wait_for_completion=True))
self.aps_scheduler.add_job(
get_announcements_to_file, 'interval', hours=6, max_instances=1, coalesce=True, misfire_grace_time=15,
- id='update_announcements', name='Update Announcements File', replace_existing=True)
+ id='update_announcements', name='Update Announcements File', replace_existing=True,
+ kwargs=dict(wait_for_completion=True))
def __search_wanted_subtitles_task(self):
if settings.general.use_sonarr:
self.aps_scheduler.add_job(
wanted_search_missing_subtitles_series, 'interval', hours=int(settings.general.wanted_search_frequency),
max_instances=1, coalesce=True, misfire_grace_time=15, id='wanted_search_missing_subtitles_series',
- replace_existing=True, name='Search for Missing Series Subtitles')
+ replace_existing=True, name='Search for Missing Series Subtitles',
+ kwargs=dict(wait_for_completion=True))
if settings.general.use_radarr:
self.aps_scheduler.add_job(
wanted_search_missing_subtitles_movies, 'interval',
hours=int(settings.general.wanted_search_frequency_movie), max_instances=1, coalesce=True,
misfire_grace_time=15, id='wanted_search_missing_subtitles_movies',
- name='Search for Missing Movies Subtitles', replace_existing=True)
+ name='Search for Missing Movies Subtitles', replace_existing=True,
+ kwargs=dict(wait_for_completion=True))
def __upgrade_subtitles_task(self):
if settings.general.use_sonarr or settings.general.use_radarr:
self.aps_scheduler.add_job(
upgrade_subtitles, 'interval', hours=int(settings.general.upgrade_frequency), max_instances=1,
- coalesce=True, misfire_grace_time=15, id='upgrade_subtitles',
+ coalesce=True, misfire_grace_time=15, id='upgrade_subtitles', kwargs=dict(wait_for_completion=True),
name='Upgrade Previously Downloaded Subtitles', replace_existing=True)
+ def __mass_sync_task(self):
+ self.aps_scheduler.add_job(
+ mass_batch_operation, 'cron', year=in_a_century(), max_instances=1,
+ coalesce=True, misfire_grace_time=15, id='mass_sync_subtitles',
+ name='Mass Sync All Subtitles', replace_existing=True)
+
def __randomize_interval_task(self):
for job in self.aps_scheduler.get_jobs():
if isinstance(job.trigger, IntervalTrigger):
diff --git a/bazarr/app/ui.py b/bazarr/app/ui.py
index 09193837ab..77964c8f84 100644
--- a/bazarr/app/ui.py
+++ b/bazarr/app/ui.py
@@ -1,13 +1,15 @@
# coding=utf-8
import os
+import ipaddress
+import socket
import requests
import mimetypes
from flask import (request, abort, render_template, Response, session, send_file, stream_with_context, Blueprint,
redirect)
from functools import wraps
-from urllib.parse import unquote
+from urllib.parse import unquote, urlparse
from constants import HEADERS
from literals import FILE_LOG
@@ -16,7 +18,7 @@
from utilities.helper import check_credentials
from utilities.central import get_log_file_path
-from .config import settings, base_url
+from .config import settings, base_url, get_ssl_verify
from .database import database, System
from .get_args import args
@@ -132,7 +134,7 @@ def series_images(url):
baseUrl = settings.sonarr.base_url
url_image = f'{url_api_sonarr()}{url.lstrip(baseUrl)}?apikey={apikey}'.replace('poster-250', 'poster-500')
try:
- req = requests.get(url_image, stream=True, timeout=15, verify=False, headers=HEADERS)
+ req = requests.get(url_image, stream=True, timeout=15, verify=get_ssl_verify('sonarr'), headers=HEADERS)
except Exception:
return '', 404
else:
@@ -146,7 +148,7 @@ def movies_images(url):
baseUrl = settings.radarr.base_url
url_image = f'{url_api_radarr()}{url.lstrip(baseUrl)}?apikey={apikey}'
try:
- req = requests.get(url_image, stream=True, timeout=15, verify=False, headers=HEADERS)
+ req = requests.get(url_image, stream=True, timeout=15, verify=get_ssl_verify('radarr'), headers=HEADERS)
except Exception:
return '', 404
else:
@@ -175,6 +177,30 @@ def swaggerui_static(filename):
return send_file(fullpath)
+def _resolve_and_validate(url_str):
+ """Resolve DNS once and validate resolved IPs. Pick a safe address to pin to.
+ Returns (resolved_ip, hostname, parsed) or raises ValueError."""
+ parsed = urlparse(url_str)
+ hostname = parsed.hostname
+ if not hostname:
+ raise ValueError("No hostname in URL")
+ port = parsed.port or (443 if parsed.scheme == 'https' else 80)
+ addrs = socket.getaddrinfo(hostname, port)
+ if not addrs:
+ raise ValueError("DNS resolution returned no results")
+ # Find a safe (non-link-local, non-loopback) address to pin to.
+ # Dual-stack hosts may resolve to both private LAN and link-local IPv6.
+ safe_ip = None
+ for _, _, _, _, sockaddr in addrs:
+ ip = ipaddress.ip_address(sockaddr[0])
+ if not ip.is_link_local and not ip.is_loopback:
+ if safe_ip is None:
+ safe_ip = sockaddr[0]
+ if safe_ip is None:
+ raise ValueError("All resolved addresses are link-local or loopback")
+ return safe_ip, hostname, parsed
+
+
@check_login
@ui_bp.route('/test', methods=['GET'])
@ui_bp.route('/test//', methods=['GET'])
@@ -182,9 +208,19 @@ def proxy(protocol, url):
if protocol.lower() not in ['http', 'https']:
return dict(status=False, error='Unsupported protocol', code=0)
url = f'{protocol}://{unquote(url)}'
+ try:
+ resolved_ip, hostname, parsed = _resolve_and_validate(url)
+ except (ValueError, socket.gaierror) as e:
+ return dict(status=False, error=f'Request blocked: {e}', code=0)
+ # Pin request to resolved IP to prevent DNS rebinding
+ port = parsed.port or (443 if parsed.scheme == 'https' else 80)
+ pinned_netloc = f'{resolved_ip}:{port}'
+ pinned_url = parsed._replace(netloc=pinned_netloc).geturl()
+ pinned_headers = dict(HEADERS)
+ pinned_headers['Host'] = hostname
params = request.args
try:
- result = requests.get(url, params, allow_redirects=False, verify=False, timeout=5, headers=HEADERS)
+ result = requests.get(pinned_url, params, allow_redirects=False, verify=False, timeout=5, headers=pinned_headers)
except Exception as e:
return dict(status=False, error=repr(e))
else:
diff --git a/bazarr/literals.py b/bazarr/literals.py
index a76fa2ceec..09ba60d9b5 100644
--- a/bazarr/literals.py
+++ b/bazarr/literals.py
@@ -29,3 +29,4 @@
EXIT_PYTHON_UPGRADE_NEEDED = -103
EXIT_REQUIREMENTS_ERROR = -104
EXIT_PORT_ALREADY_IN_USE_ERROR = -105
+EXIT_UNEXPECTED_ERROR = -106
diff --git a/bazarr/main.py b/bazarr/main.py
index aa2884aad7..61c8690568 100644
--- a/bazarr/main.py
+++ b/bazarr/main.py
@@ -38,7 +38,7 @@
else:
# we want to update to the latest version before loading too much stuff. This should prevent deadlock when
# there's missing embedded packages after a commit
- check_if_new_update()
+ check_if_new_update(startup=True)
from app.database import (System, database, update, migrate_db, create_db_revision, upgrade_languages_profile_values,
fix_languages_profiles_with_duplicate_ids) # noqa E402
diff --git a/bazarr/radarr/filesystem.py b/bazarr/radarr/filesystem.py
index 72a83cdb27..230665ea6e 100644
--- a/bazarr/radarr/filesystem.py
+++ b/bazarr/radarr/filesystem.py
@@ -3,7 +3,7 @@
import requests
import logging
-from app.config import settings
+from app.config import settings, get_ssl_verify
from radarr.info import url_api_radarr
from constants import HEADERS
@@ -15,7 +15,7 @@ def browse_radarr_filesystem(path='#'):
url_radarr_api_filesystem = (f"{url_api_radarr()}filesystem?path={path}&allowFoldersWithoutTrailingSlashes=true&"
f"includeFiles=false&apikey={settings.radarr.apikey}")
try:
- r = requests.get(url_radarr_api_filesystem, timeout=int(settings.radarr.http_timeout), verify=False,
+ r = requests.get(url_radarr_api_filesystem, timeout=int(settings.radarr.http_timeout), verify=get_ssl_verify('radarr'),
headers=HEADERS)
r.raise_for_status()
except requests.exceptions.HTTPError:
diff --git a/bazarr/radarr/history.py b/bazarr/radarr/history.py
index d0cfe78c77..3898c35c5a 100644
--- a/bazarr/radarr/history.py
+++ b/bazarr/radarr/history.py
@@ -2,6 +2,8 @@
from datetime import datetime
+from subliminal_patch.score import MAX_SCORES
+
from app.database import TableHistoryMovie, database, insert
from app.event_handler import event_stream
@@ -28,6 +30,7 @@ def history_log_movie(action, radarr_id, result, fake_provider=None, fake_score=
language=language,
provider=provider,
score=score,
+ score_out_of=MAX_SCORES['movie'] if score else None,
subs_id=subs_id,
subtitles_path=subtitles_path,
matched=str(matched) if matched else None,
diff --git a/bazarr/radarr/info.py b/bazarr/radarr/info.py
index ca6842d1ee..129be6803f 100644
--- a/bazarr/radarr/info.py
+++ b/bazarr/radarr/info.py
@@ -5,11 +5,11 @@
import datetime
import semver
-from requests.exceptions import JSONDecodeError
+from requests.exceptions import JSONDecodeError, RequestException
from dogpile.cache import make_region
-from app.config import settings, empty_values
+from app.config import settings, empty_values, get_ssl_verify
from constants import HEADERS
region = make_region().configure('dogpile.cache.memory')
@@ -31,7 +31,7 @@ def version():
if settings.general.use_radarr:
try:
rv = f"{url_radarr()}/api/system/status?apikey={settings.radarr.apikey}"
- radarr_json = requests.get(rv, timeout=int(settings.radarr.http_timeout), verify=False,
+ radarr_json = requests.get(rv, timeout=int(settings.radarr.http_timeout), verify=get_ssl_verify('radarr'),
headers=HEADERS).json()
if 'version' in radarr_json:
radarr_version = radarr_json['version']
@@ -40,9 +40,9 @@ def version():
except JSONDecodeError:
try:
rv = f"{url_radarr()}/api/v3/system/status?apikey={settings.radarr.apikey}"
- radarr_version = requests.get(rv, timeout=int(settings.radarr.http_timeout), verify=False,
+ radarr_version = requests.get(rv, timeout=int(settings.radarr.http_timeout), verify=get_ssl_verify('radarr'),
headers=HEADERS).json()['version']
- except JSONDecodeError:
+ except (RequestException, JSONDecodeError, KeyError):
logging.debug('BAZARR cannot get Radarr version')
radarr_version = 'unknown'
except Exception:
diff --git a/bazarr/radarr/notify.py b/bazarr/radarr/notify.py
index 3c74e1d5b8..35a6194977 100644
--- a/bazarr/radarr/notify.py
+++ b/bazarr/radarr/notify.py
@@ -3,7 +3,7 @@
import logging
import requests
-from app.config import settings
+from app.config import settings, get_ssl_verify
from radarr.info import url_api_radarr
from constants import HEADERS
@@ -15,6 +15,6 @@ def notify_radarr(radarr_id):
'name': 'RescanMovie',
'movieId': int(radarr_id)
}
- requests.post(url, json=data, timeout=int(settings.radarr.http_timeout), verify=False, headers=HEADERS)
+ requests.post(url, json=data, timeout=int(settings.radarr.http_timeout), verify=get_ssl_verify('radarr'), headers=HEADERS)
except Exception:
logging.exception('BAZARR cannot notify Radarr')
diff --git a/bazarr/radarr/rootfolder.py b/bazarr/radarr/rootfolder.py
index 1320cff8e3..24de040f61 100644
--- a/bazarr/radarr/rootfolder.py
+++ b/bazarr/radarr/rootfolder.py
@@ -4,7 +4,7 @@
import requests
import logging
-from app.config import settings
+from app.config import settings, get_ssl_verify
from utilities.path_mappings import path_mappings
from app.database import TableMoviesRootfolder, TableMovies, database, delete, update, insert, select
from radarr.info import url_api_radarr
@@ -19,7 +19,7 @@ def get_radarr_rootfolder():
url_radarr_api_rootfolder = f"{url_api_radarr()}rootfolder?apikey={apikey_radarr}"
try:
- rootfolder = requests.get(url_radarr_api_rootfolder, timeout=int(settings.radarr.http_timeout), verify=False, headers=HEADERS)
+ rootfolder = requests.get(url_radarr_api_rootfolder, timeout=int(settings.radarr.http_timeout), verify=get_ssl_verify('radarr'), headers=HEADERS)
except requests.exceptions.ConnectionError:
logging.exception("BAZARR Error trying to get rootfolder from Radarr. Connection Error.")
return []
diff --git a/bazarr/radarr/sync/movies.py b/bazarr/radarr/sync/movies.py
index 3e9ae52373..f23c649c77 100644
--- a/bazarr/radarr/sync/movies.py
+++ b/bazarr/radarr/sync/movies.py
@@ -85,9 +85,10 @@ def add_movie(added_movie):
event_stream(type='movie', action='update', payload=int(added_movie['radarrId']))
-def update_movies(job_id=None):
+def update_movies(job_id=None, wait_for_completion=False):
if not job_id:
- jobs_queue.add_job_from_function("Syncing movies with Radarr", is_progress=True)
+ jobs_queue.add_job_from_function("Syncing movies with Radarr", is_progress=True,
+ wait_for_completion=wait_for_completion)
return
check_radarr_rootfolder()
diff --git a/bazarr/radarr/sync/utils.py b/bazarr/radarr/sync/utils.py
index d0d13c2531..eb21b47151 100644
--- a/bazarr/radarr/sync/utils.py
+++ b/bazarr/radarr/sync/utils.py
@@ -3,7 +3,7 @@
import requests
import logging
-from app.config import settings
+from app.config import settings, get_ssl_verify
from radarr.info import get_radarr_info, url_api_radarr
from constants import HEADERS
@@ -16,7 +16,7 @@ def get_profile_list():
f"apikey={apikey_radarr}")
try:
- profiles_json = requests.get(url_radarr_api_movies, timeout=int(settings.radarr.http_timeout), verify=False,
+ profiles_json = requests.get(url_radarr_api_movies, timeout=int(settings.radarr.http_timeout), verify=get_ssl_verify('radarr'),
headers=HEADERS)
except requests.exceptions.ConnectionError:
logging.exception("BAZARR Error trying to get profiles from Radarr. Connection Error.")
@@ -46,7 +46,7 @@ def get_tags():
url_radarr_api_series = f"{url_api_radarr()}tag?apikey={apikey_radarr}"
try:
- tagsDict = requests.get(url_radarr_api_series, timeout=int(settings.radarr.http_timeout), verify=False, headers=HEADERS)
+ tagsDict = requests.get(url_radarr_api_series, timeout=int(settings.radarr.http_timeout), verify=get_ssl_verify('radarr'), headers=HEADERS)
except requests.exceptions.ConnectionError:
logging.exception("BAZARR Error trying to get tags from Radarr. Connection Error.")
return []
@@ -70,7 +70,7 @@ def get_movies_from_radarr_api(apikey_radarr, radarr_id=None):
url_radarr_api_movies = f'{url_api_radarr()}movie{f"/{radarr_id}" if radarr_id else ""}?apikey={apikey_radarr}'
try:
- r = requests.get(url_radarr_api_movies, timeout=int(settings.radarr.http_timeout), verify=False, headers=HEADERS)
+ r = requests.get(url_radarr_api_movies, timeout=int(settings.radarr.http_timeout), verify=get_ssl_verify('radarr'), headers=HEADERS)
if r.status_code == 404:
return
r.raise_for_status()
@@ -100,7 +100,7 @@ def get_history_from_radarr_api(apikey_radarr, movie_id):
url_radarr_api_history = f"{url_api_radarr()}history?eventType=1&movieIds={movie_id}&apikey={apikey_radarr}"
try:
- r = requests.get(url_radarr_api_history, timeout=int(settings.sonarr.http_timeout), verify=False,
+ r = requests.get(url_radarr_api_history, timeout=int(settings.sonarr.http_timeout), verify=get_ssl_verify('radarr'),
headers=HEADERS)
r.raise_for_status()
except requests.exceptions.HTTPError:
diff --git a/bazarr/sonarr/filesystem.py b/bazarr/sonarr/filesystem.py
index 58790c7009..4d9f4c4c53 100644
--- a/bazarr/sonarr/filesystem.py
+++ b/bazarr/sonarr/filesystem.py
@@ -3,7 +3,7 @@
import requests
import logging
-from app.config import settings
+from app.config import settings, get_ssl_verify
from sonarr.info import url_api_sonarr
from constants import HEADERS
@@ -14,7 +14,7 @@ def browse_sonarr_filesystem(path='#'):
url_sonarr_api_filesystem = (f"{url_api_sonarr()}filesystem?path={path}&allowFoldersWithoutTrailingSlashes=true&"
f"includeFiles=false&apikey={settings.sonarr.apikey}")
try:
- r = requests.get(url_sonarr_api_filesystem, timeout=int(settings.sonarr.http_timeout), verify=False,
+ r = requests.get(url_sonarr_api_filesystem, timeout=int(settings.sonarr.http_timeout), verify=get_ssl_verify('sonarr'),
headers=HEADERS)
r.raise_for_status()
except requests.exceptions.HTTPError:
diff --git a/bazarr/sonarr/history.py b/bazarr/sonarr/history.py
index c3f285aabb..fc1e513a84 100644
--- a/bazarr/sonarr/history.py
+++ b/bazarr/sonarr/history.py
@@ -2,6 +2,8 @@
from datetime import datetime
+from subliminal_patch.score import MAX_SCORES
+
from app.database import TableHistory, database, insert
from app.event_handler import event_stream
@@ -30,6 +32,7 @@ def history_log(action, sonarr_series_id, sonarr_episode_id, result, fake_provid
language=language,
provider=provider,
score=score,
+ score_out_of=MAX_SCORES['episode'] if score else None,
subs_id=subs_id,
subtitles_path=subtitles_path,
matched=str(matched) if matched else None,
diff --git a/bazarr/sonarr/info.py b/bazarr/sonarr/info.py
index 6680789855..31508195ec 100644
--- a/bazarr/sonarr/info.py
+++ b/bazarr/sonarr/info.py
@@ -5,11 +5,11 @@
import datetime
import semver
-from requests.exceptions import JSONDecodeError
+from requests.exceptions import JSONDecodeError, RequestException
from dogpile.cache import make_region
-from app.config import settings, empty_values
+from app.config import settings, empty_values, get_ssl_verify
from constants import HEADERS
region = make_region().configure('dogpile.cache.memory')
@@ -31,7 +31,7 @@ def version():
if settings.general.use_sonarr:
try:
sv = f"{url_sonarr()}/api/system/status?apikey={settings.sonarr.apikey}"
- sonarr_json = requests.get(sv, timeout=int(settings.sonarr.http_timeout), verify=False,
+ sonarr_json = requests.get(sv, timeout=int(settings.sonarr.http_timeout), verify=get_ssl_verify('sonarr'),
headers=HEADERS).json()
if 'version' in sonarr_json:
sonarr_version = sonarr_json['version']
@@ -40,9 +40,9 @@ def version():
except JSONDecodeError:
try:
sv = f"{url_sonarr()}/api/v3/system/status?apikey={settings.sonarr.apikey}"
- sonarr_version = requests.get(sv, timeout=int(settings.sonarr.http_timeout), verify=False,
+ sonarr_version = requests.get(sv, timeout=int(settings.sonarr.http_timeout), verify=get_ssl_verify('sonarr'),
headers=HEADERS).json()['version']
- except JSONDecodeError:
+ except (RequestException, JSONDecodeError, KeyError):
logging.debug('BAZARR cannot get Sonarr version')
sonarr_version = 'unknown'
except Exception:
diff --git a/bazarr/sonarr/notify.py b/bazarr/sonarr/notify.py
index ee6207844e..ca0e8f56c9 100644
--- a/bazarr/sonarr/notify.py
+++ b/bazarr/sonarr/notify.py
@@ -3,7 +3,7 @@
import logging
import requests
-from app.config import settings
+from app.config import settings, get_ssl_verify
from sonarr.info import url_api_sonarr
from constants import HEADERS
@@ -15,6 +15,6 @@ def notify_sonarr(sonarr_series_id):
'name': 'RescanSeries',
'seriesId': int(sonarr_series_id)
}
- requests.post(url, json=data, timeout=int(settings.sonarr.http_timeout), verify=False, headers=HEADERS)
+ requests.post(url, json=data, timeout=int(settings.sonarr.http_timeout), verify=get_ssl_verify('sonarr'), headers=HEADERS)
except Exception:
logging.exception('BAZARR cannot notify Sonarr')
diff --git a/bazarr/sonarr/rootfolder.py b/bazarr/sonarr/rootfolder.py
index 46677a1e5b..f72c607e60 100644
--- a/bazarr/sonarr/rootfolder.py
+++ b/bazarr/sonarr/rootfolder.py
@@ -4,7 +4,7 @@
import requests
import logging
-from app.config import settings
+from app.config import settings, get_ssl_verify
from app.database import TableShowsRootfolder, TableShows, database, insert, update, delete, select
from utilities.path_mappings import path_mappings
from sonarr.info import url_api_sonarr
@@ -19,7 +19,7 @@ def get_sonarr_rootfolder():
url_sonarr_api_rootfolder = f"{url_api_sonarr()}rootfolder?apikey={apikey_sonarr}"
try:
- rootfolder = requests.get(url_sonarr_api_rootfolder, timeout=int(settings.sonarr.http_timeout), verify=False, headers=HEADERS)
+ rootfolder = requests.get(url_sonarr_api_rootfolder, timeout=int(settings.sonarr.http_timeout), verify=get_ssl_verify('sonarr'), headers=HEADERS)
except requests.exceptions.ConnectionError:
logging.exception("BAZARR Error trying to get rootfolder from Sonarr. Connection Error.")
return []
diff --git a/bazarr/sonarr/sync/series.py b/bazarr/sonarr/sync/series.py
index fc9b451bef..8ff2976e2a 100644
--- a/bazarr/sonarr/sync/series.py
+++ b/bazarr/sonarr/sync/series.py
@@ -1,6 +1,7 @@
# coding=utf-8
import logging
+import gc
from sqlalchemy.exc import IntegrityError
from datetime import datetime
@@ -41,39 +42,20 @@ def get_series_monitored_table():
return series_dict
-def update_series(job_id=None):
+def update_series(job_id=None, wait_for_completion=False):
if not job_id:
- jobs_queue.add_job_from_function("Syncing series with Sonarr", is_progress=True)
+ jobs_queue.add_job_from_function("Syncing series with Sonarr", is_progress=True,
+ wait_for_completion=wait_for_completion)
return
+ # Update root folders and update their health status
check_sonarr_rootfolder()
- apikey_sonarr = settings.sonarr.apikey
- if apikey_sonarr is None:
- return
-
- serie_default_enabled = settings.general.serie_default_enabled
-
- if serie_default_enabled is True:
- serie_default_profile = settings.general.serie_default_profile
- if serie_default_profile == '':
- serie_default_profile = None
- else:
- serie_default_profile = None
-
- # Prevent trying to insert a series with a non-existing languages profileId
- if (serie_default_profile and not database.execute(
- select(TableLanguagesProfiles)
- .where(TableLanguagesProfiles.profileId == serie_default_profile))
- .first()):
- serie_default_profile = None
-
- audio_profiles = get_profile_list()
- tagsDict = get_tags()
- language_profiles = get_language_profiles()
# Get shows data from Sonarr
- series = get_series_from_sonarr_api(apikey_sonarr=apikey_sonarr)
- if not isinstance(series, list):
+ try:
+ series = get_series_from_sonarr_api(apikey_sonarr=settings.sonarr.apikey)
+ except Exception as e:
+ logging.exception(f"BAZARR Error trying to get series from Sonarr: {e}")
return
else:
# Get current shows in DB
@@ -81,19 +63,24 @@ def update_series(job_id=None):
database.execute(
select(TableShows.sonarrSeriesId))
.all()]
+
current_shows_sonarr = []
series_count = len(series)
- sync_monitored = settings.sonarr.sync_only_monitored_series
- if sync_monitored:
+ skipped_count = 0
+
+ series_monitored = None
+ if settings.sonarr.sync_only_monitored_series:
+ # Get current series monitored status in DB
series_monitored = get_series_monitored_table()
- skipped_count = 0
+
trace(f"Starting sync for {series_count} shows")
jobs_queue.update_job_progress(job_id=job_id, progress_max=series_count)
for i, show in enumerate(series, start=1):
jobs_queue.update_job_progress(job_id=job_id, progress_value=i, progress_message=show['title'])
- if sync_monitored:
+
+ if settings.sonarr.sync_only_monitored_series:
try:
monitored_status_db = bool_map[series_monitored[show['id']]]
except KeyError:
@@ -113,71 +100,32 @@ def update_series(job_id=None):
continue
trace(f"{i}: (Processing) {show['title']}")
+
# Add shows in Sonarr to current shows list
current_shows_sonarr.append(show['id'])
- if show['id'] in current_shows_db:
- updated_series = seriesParser(show, action='update', tags_dict=tagsDict,
- language_profiles=language_profiles,
- serie_default_profile=serie_default_profile,
- audio_profiles=audio_profiles)
-
- if not database.execute(
- select(TableShows)
- .filter_by(**updated_series))\
- .first():
- try:
- trace(f"Updating {show['title']}")
- updated_series['updated_at_timestamp'] = datetime.now()
- database.execute(
- update(TableShows)
- .values(updated_series)
- .where(TableShows.sonarrSeriesId == show['id']))
- except IntegrityError as e:
- logging.error(f"BAZARR cannot update series {updated_series['path']} because of {e}")
- continue
-
- event_stream(type='series', payload=show['id'])
- else:
- added_series = seriesParser(show, action='insert', tags_dict=tagsDict,
- language_profiles=language_profiles,
- serie_default_profile=serie_default_profile,
- audio_profiles=audio_profiles)
-
- try:
- trace(f"Inserting {show['title']}")
- added_series['created_at_timestamp'] = datetime.now()
- database.execute(
- insert(TableShows)
- .values(added_series))
- except IntegrityError as e:
- logging.error(f"BAZARR cannot insert series {added_series['path']} because of {e}")
- continue
- else:
- list_missing_subtitles(no=show['id'])
-
- event_stream(type='series', action='update', payload=show['id'])
+ # Update series in DB
+ update_one_series(show['id'], action='updated')
+ # Update episodes in DB
sync_episodes(series_id=show['id'])
- # Remove old series from DB
+ # Calculate series to remove from DB
removed_series = list(set(current_shows_db) - set(current_shows_sonarr))
for series in removed_series:
- # try to avoid unnecessary database calls
- if settings.general.debug:
- series_title = database.execute(select(TableShows.title).where(TableShows.sonarrSeriesId == series)).first()[0]
- trace(f"Deleting {series_title}")
- database.execute(
- delete(TableShows)
- .where(TableShows.sonarrSeriesId == series))
- event_stream(type='series', action='delete', payload=series)
-
- if sync_monitored:
- trace(f"skipped {skipped_count} unmonitored series out of {i}")
+ # Remove series from DB
+ update_one_series(series, action='deleted')
+
+ if settings.sonarr.sync_only_monitored_series:
+ trace(f"skipped {skipped_count} unmonitored series out of {series_count}")
+
logging.debug('BAZARR All series synced from Sonarr into database.')
+
jobs_queue.update_job_name(job_id=job_id, new_job_name="Synced series with Sonarr")
+ gc.collect()
+
def update_one_series(series_id, action, is_signalr=False):
logging.debug(f'BAZARR syncing this specific series from Sonarr: {series_id}')
@@ -197,9 +145,7 @@ def update_one_series(series_id, action, is_signalr=False):
event_stream(type='series', action='delete', payload=int(series_id))
return
- serie_default_enabled = settings.general.serie_default_enabled
-
- if serie_default_enabled is True:
+ if settings.general.serie_default_enabled is True:
serie_default_profile = settings.general.serie_default_profile
if serie_default_profile == '':
serie_default_profile = None
@@ -209,58 +155,56 @@ def update_one_series(series_id, action, is_signalr=False):
audio_profiles = get_profile_list()
tagsDict = get_tags()
language_profiles = get_language_profiles()
+
try:
# Get series data from sonarr api
- series = None
-
series_data = get_series_from_sonarr_api(apikey_sonarr=settings.sonarr.apikey, sonarr_series_id=int(series_id))
-
+ except Exception:
+ logging.exception(f'BAZARR cannot get series with ID {series_id} from Sonarr API.')
+ return
+ else:
if not series_data:
return
else:
if action == 'updated' and existing_series:
+ # Update existing series in DB
series = seriesParser(series_data[0], action='update', tags_dict=tagsDict,
language_profiles=language_profiles,
serie_default_profile=serie_default_profile,
audio_profiles=audio_profiles)
+ try:
+ series['updated_at_timestamp'] = datetime.now()
+ database.execute(
+ update(TableShows)
+ .values(series)
+ .where(TableShows.sonarrSeriesId == series['sonarrSeriesId']))
+ except IntegrityError as e:
+ logging.error(f"BAZARR cannot update series {series['path']} because of {e}")
+ else:
+ if not is_signalr:
+ # Sonarr emit two SignalR events when episodes must be refreshed.
+ # The one that gets there doesn't include the episodeChanged flag.
+ # The episodes are synced only when this function is called from the
+ # frontend sync button in the episodes' page.
+ sync_episodes(series_id=int(series_id))
+ event_stream(type='series', action='update', payload=int(series_id))
+ logging.debug(
+ f'BAZARR updated this series into the database:{path_mappings.path_replace(series["path"])}')
elif action == 'updated' and not existing_series:
+ # Insert new series in DB
series = seriesParser(series_data[0], action='insert', tags_dict=tagsDict,
language_profiles=language_profiles,
serie_default_profile=serie_default_profile,
audio_profiles=audio_profiles)
- except Exception:
- logging.exception('BAZARR cannot get series returned by SignalR feed from Sonarr API.')
- return
- # Update existing series in DB
- if action == 'updated' and existing_series:
- try:
- series['updated_at_timestamp'] = datetime.now()
- database.execute(
- update(TableShows)
- .values(series)
- .where(TableShows.sonarrSeriesId == series['sonarrSeriesId']))
- except IntegrityError as e:
- logging.error(f"BAZARR cannot update series {series['path']} because of {e}")
- else:
- if not is_signalr:
- # Sonarr emit two SignalR events when episodes must be refreshed.
- # The one that gets there doesn't include the episodeChanged flag.
- # The episodes are synced only when this function is called from the
- # frontend sync button in the episodes' page.
- sync_episodes(series_id=int(series_id))
- event_stream(type='series', action='update', payload=int(series_id))
- logging.debug(f'BAZARR updated this series into the database:{path_mappings.path_replace(series["path"])}')
-
- # Insert new series in DB
- elif action == 'updated' and not existing_series:
- try:
- series['created_at_timestamp'] = datetime.now()
- database.execute(
- insert(TableShows)
- .values(series))
- except IntegrityError as e:
- logging.error(f"BAZARR cannot insert series {series['path']} because of {e}")
- else:
- event_stream(type='series', action='update', payload=int(series_id))
- logging.debug(f'BAZARR inserted this series into the database:{path_mappings.path_replace(series["path"])}')
+ try:
+ series['created_at_timestamp'] = datetime.now()
+ database.execute(
+ insert(TableShows)
+ .values(series))
+ except IntegrityError as e:
+ logging.error(f"BAZARR cannot insert series {series['path']} because of {e}")
+ else:
+ event_stream(type='series', action='update', payload=int(series_id))
+ logging.debug(
+ f'BAZARR inserted this series into the database:{path_mappings.path_replace(series["path"])}')
diff --git a/bazarr/sonarr/sync/utils.py b/bazarr/sonarr/sync/utils.py
index 911fca6902..18301c391a 100644
--- a/bazarr/sonarr/sync/utils.py
+++ b/bazarr/sonarr/sync/utils.py
@@ -3,7 +3,7 @@
import requests
import logging
-from app.config import settings
+from app.config import settings, get_ssl_verify
from sonarr.info import get_sonarr_info, url_api_sonarr
from constants import HEADERS
@@ -22,7 +22,7 @@ def get_profile_list():
url_sonarr_api_series = f"{url_api_sonarr()}languageprofile?apikey={apikey_sonarr}"
try:
- profiles_json = requests.get(url_sonarr_api_series, timeout=int(settings.sonarr.http_timeout), verify=False,
+ profiles_json = requests.get(url_sonarr_api_series, timeout=int(settings.sonarr.http_timeout), verify=get_ssl_verify('sonarr'),
headers=HEADERS)
except requests.exceptions.ConnectionError:
logging.exception("BAZARR Error trying to get profiles from Sonarr. Connection Error.")
@@ -55,7 +55,7 @@ def get_tags():
url_sonarr_api_series = f"{url_api_sonarr()}tag?apikey={apikey_sonarr}"
try:
- tagsDict = requests.get(url_sonarr_api_series, timeout=int(settings.sonarr.http_timeout), verify=False, headers=HEADERS)
+ tagsDict = requests.get(url_sonarr_api_series, timeout=int(settings.sonarr.http_timeout), verify=get_ssl_verify('sonarr'), headers=HEADERS)
except requests.exceptions.ConnectionError:
logging.exception("BAZARR Error trying to get tags from Sonarr. Connection Error.")
return []
@@ -73,7 +73,7 @@ def get_series_from_sonarr_api(apikey_sonarr, sonarr_series_id=None):
url_sonarr_api_series = (f"{url_api_sonarr()}series/{sonarr_series_id if sonarr_series_id else ''}?"
f"apikey={apikey_sonarr}")
try:
- r = requests.get(url_sonarr_api_series, timeout=int(settings.sonarr.http_timeout), verify=False, headers=HEADERS)
+ r = requests.get(url_sonarr_api_series, timeout=int(settings.sonarr.http_timeout), verify=get_ssl_verify('sonarr'), headers=HEADERS)
r.raise_for_status()
except requests.exceptions.HTTPError as e:
if e.response.status_code:
@@ -113,7 +113,7 @@ def get_episodes_from_sonarr_api(apikey_sonarr, series_id=None, episode_id=None)
return
try:
- r = requests.get(url_sonarr_api_episode, timeout=int(settings.sonarr.http_timeout), verify=False, headers=HEADERS)
+ r = requests.get(url_sonarr_api_episode, timeout=int(settings.sonarr.http_timeout), verify=get_ssl_verify('sonarr'), headers=HEADERS)
r.raise_for_status()
except requests.exceptions.HTTPError:
logging.exception("BAZARR Error trying to get episodes from Sonarr. Http error.")
@@ -146,7 +146,7 @@ def get_episodesFiles_from_sonarr_api(apikey_sonarr, series_id=None, episode_fil
return
try:
- r = requests.get(url_sonarr_api_episodeFiles, timeout=int(settings.sonarr.http_timeout), verify=False,
+ r = requests.get(url_sonarr_api_episodeFiles, timeout=int(settings.sonarr.http_timeout), verify=get_ssl_verify('sonarr'),
headers=HEADERS)
r.raise_for_status()
except requests.exceptions.HTTPError:
@@ -175,7 +175,7 @@ def get_history_from_sonarr_api(apikey_sonarr, episode_id):
url_sonarr_api_history = f"{url_api_sonarr()}history?eventType=1&episodeId={episode_id}&apikey={apikey_sonarr}"
try:
- r = requests.get(url_sonarr_api_history, timeout=int(settings.sonarr.http_timeout), verify=False,
+ r = requests.get(url_sonarr_api_history, timeout=int(settings.sonarr.http_timeout), verify=get_ssl_verify('sonarr'),
headers=HEADERS)
r.raise_for_status()
except requests.exceptions.HTTPError:
diff --git a/bazarr/subtitles/cache.py b/bazarr/subtitles/cache.py
new file mode 100644
index 0000000000..5abe50b300
--- /dev/null
+++ b/bazarr/subtitles/cache.py
@@ -0,0 +1,44 @@
+# coding=utf-8
+
+import time
+import threading
+import uuid
+
+
+_TTL_SECONDS = 3600 # 1 hour
+
+
+class _SubtitleCache:
+ def __init__(self):
+ self._cache = {}
+ self._lock = threading.Lock()
+
+ def _purge_expired(self):
+ now = time.monotonic()
+ expired = [k for k, (_, expiry) in self._cache.items() if now >= expiry]
+ for k in expired:
+ del self._cache[k]
+
+ def store(self, subtitle):
+ """Store a subtitle object and return its cache key (UUID string)."""
+ key = str(uuid.uuid4())
+ expiry = time.monotonic() + _TTL_SECONDS
+ with self._lock:
+ self._purge_expired()
+ self._cache[key] = (subtitle, expiry)
+ return key
+
+ def get(self, key):
+ """Return the subtitle object for the given key, or None if not found/expired."""
+ with self._lock:
+ entry = self._cache.get(key)
+ if entry is None:
+ return None
+ subtitle, expiry = entry
+ if time.monotonic() >= expiry:
+ del self._cache[key]
+ return None
+ return subtitle
+
+
+subtitle_cache = _SubtitleCache()
diff --git a/bazarr/subtitles/download.py b/bazarr/subtitles/download.py
index 2d44e5ae58..a323240c9e 100644
--- a/bazarr/subtitles/download.py
+++ b/bazarr/subtitles/download.py
@@ -10,9 +10,8 @@
from subzero.language import Language
from subliminal_patch.core import save_subtitles
from subliminal_patch.core_persistent import download_best_subtitles
-from subliminal_patch.score import ComputeScore
-from app.config import settings, get_scores, get_array_from
+from app.config import settings, get_array_from
from app.database import TableEpisodes, TableMovies, database, select, get_profiles_list
from utilities.path_mappings import path_mappings
from utilities.helper import get_target_folder, force_unicode
@@ -63,6 +62,21 @@ def generate_subtitles(path, languages, audio_language, sceneName, title, media_
saved_any = False
if providers:
+ if job_id:
+ from app.jobs_queue import jobs_queue as _jq
+ active_providers = [p for p in providers if p not in pool.discarded_providers]
+ _provider_count = len(active_providers)
+
+ def _on_provider(provider_name):
+ try:
+ idx = active_providers.index(provider_name) + 1
+ except ValueError:
+ idx = 0
+ _jq.update_job_progress(job_id=job_id,
+ progress_message=f"Searching {provider_name} ({idx}/{_provider_count})")
+
+ pool.provider_progress_callback = _on_provider
+
if forced_minimum_score:
min_score = int(forced_minimum_score) + 1
for language in language_set:
@@ -79,8 +93,8 @@ def generate_subtitles(path, languages, audio_language, sceneName, title, media_
pool_instance=pool,
min_score=int(min_score),
hearing_impaired=hi_required,
- compute_score=ComputeScore(get_scores()),
- use_original_format=original_format in (1, "1", "True", True))
+ use_original_format=original_format in (1, "1", "True", True),
+ use_provider_priority=settings.general.use_provider_priority)
except Exception as e:
logging.exception(f'BAZARR Error downloading Subtitles for this file {path}: {repr(e)}')
return None
@@ -121,6 +135,10 @@ def generate_subtitles(path, languages, audio_language, sceneName, title, media_
else:
saved_any = True
for subtitle in saved_subtitles:
+ if "hash" in subtitle.matches:
+ # make matches set cleaner for history purpose when hash matches
+ subtitle.matches = {match for match in subtitle.matches
+ if match in ("hash", "hearing_impaired")}
processed_subtitle = process_subtitle(subtitle=subtitle, media_type=media_type,
audio_language=audio_language,
is_upgrade=is_upgrade, is_manual=False,
diff --git a/bazarr/subtitles/indexer/movies.py b/bazarr/subtitles/indexer/movies.py
index 8e9498653d..cbf1017be6 100644
--- a/bazarr/subtitles/indexer/movies.py
+++ b/bazarr/subtitles/indexer/movies.py
@@ -277,9 +277,10 @@ def list_missing_subtitles_movies(no=None):
event_stream(type='badges')
-def movies_full_scan_subtitles(job_id=None, use_cache=None):
+def movies_full_scan_subtitles(job_id=None, use_cache=None, wait_for_completion=False):
if not job_id:
- jobs_queue.add_job_from_function("Indexing all existing movies subtitles", is_progress=True)
+ jobs_queue.add_job_from_function("Indexing all existing movies subtitles", is_progress=True,
+ wait_for_completion=wait_for_completion)
return
if use_cache is None:
diff --git a/bazarr/subtitles/indexer/series.py b/bazarr/subtitles/indexer/series.py
index 9fe20ba6a2..24ed63f34c 100644
--- a/bazarr/subtitles/indexer/series.py
+++ b/bazarr/subtitles/indexer/series.py
@@ -282,9 +282,10 @@ def list_missing_subtitles(no=None, epno=None):
event_stream(type='badges')
-def series_full_scan_subtitles(job_id=None, use_cache=None):
+def series_full_scan_subtitles(job_id=None, use_cache=None, wait_for_completion=False):
if not job_id:
- jobs_queue.add_job_from_function("Indexing all existing episodes subtitles", is_progress=True)
+ jobs_queue.add_job_from_function("Indexing all existing episodes subtitles", is_progress=True,
+ wait_for_completion=wait_for_completion)
return
if use_cache is None:
diff --git a/bazarr/subtitles/manual.py b/bazarr/subtitles/manual.py
index e943abe1b1..9818291532 100644
--- a/bazarr/subtitles/manual.py
+++ b/bazarr/subtitles/manual.py
@@ -4,17 +4,15 @@
import os
import sys
import logging
-import pickle
-import codecs
import subliminal
from subzero.language import Language
from subliminal_patch.core import save_subtitles
from subliminal_patch.core_persistent import list_all_subtitles, download_subtitles
-from subliminal_patch.score import ComputeScore
+from subliminal_patch.score import compute_score, DEFAULT_SCORES
from languages.get_languages import alpha3_from_alpha2
-from app.config import get_scores, settings, get_array_from
+from app.config import settings, get_array_from
from utilities.helper import get_target_folder, force_unicode
from utilities.path_mappings import path_mappings
from app.database import (database, get_profiles_list, select, TableEpisodes, TableShows, get_audio_profile_languages,
@@ -27,6 +25,7 @@
from subtitles.indexer.movies import store_subtitles_movie
from subtitles.processing import ProcessSubtitlesResult
+from bazarr.subtitles.cache import subtitle_cache
from .pool import update_pools, _get_pool
from .utils import get_video, _get_lang_obj, _get_scores, _set_forced_providers
from .processing import process_subtitle
@@ -44,7 +43,6 @@ def manual_search(path, profile_id, providers, sceneName, title, media_type):
also_forced = any([x.forced for x in language_set])
forced_required = all([x.forced for x in language_set])
normal = not also_forced and not forced_required and all([not x.hi for x in language_set])
- compute_score = ComputeScore(get_scores())
_set_forced_providers(pool=pool, also_forced=also_forced, forced_required=forced_required)
if providers:
@@ -68,6 +66,7 @@ def manual_search(path, profile_id, providers, sceneName, title, media_type):
subtitles_list = []
minimum_score = settings.general.minimum_score
minimum_score_movie = settings.general.minimum_score_movie
+ score_handler = DEFAULT_SCORES['episode'] if media_type == "series" else DEFAULT_SCORES['movie']
for s in subtitles[video]:
if not normal and s.language not in language_set:
@@ -75,7 +74,9 @@ def manual_search(path, profile_id, providers, sceneName, title, media_type):
continue
try:
- matches = s.get_matches(video)
+ matches = s.matches if hasattr(s, 'matches') and isinstance(s.matches, set) and len(s.matches) \
+ else s.get_matches(video)
+ matches = {match for match in matches if match in score_handler.keys()} # cleanup unwanted criterion
except AttributeError:
continue
@@ -100,8 +101,10 @@ def manual_search(path, profile_id, providers, sceneName, title, media_type):
if 'hash' not in matches:
not_matched = scores - matches
+ not_matched = {match for match in not_matched if match in score_handler.keys()}
s.score = score_without_hash
else:
+ matches = s.matches = {match for match in matches if match in ("hash", "hearing_impaired")}
s.score = score
not_matched = set()
@@ -128,7 +131,7 @@ def manual_search(path, profile_id, providers, sceneName, title, media_type):
language=str(s.language.basename),
hearing_impaired=str(s.hearing_impaired),
provider=s.provider_name,
- subtitle=codecs.encode(pickle.dumps(s.make_picklable()), "base64").decode(),
+ subtitle=subtitle_cache.store(s),
url=s.page_link,
original_format=s.use_original_format,
matches=list(matches),
@@ -156,7 +159,10 @@ def manual_download_subtitle(path, audio_language, hi, forced, subtitle, provide
else:
os.environ["SZ_KEEP_ENCODING"] = "True"
- subtitle = pickle.loads(codecs.decode(subtitle.encode(), "base64"))
+ subtitle = subtitle_cache.get(subtitle)
+ if subtitle is None:
+ logging.error("BAZARR Subtitle not found in cache (expired or invalid ID)")
+ return 'Subtitle not found in cache. Please search again.'
if hi == 'True':
subtitle.language.hi = True
else:
diff --git a/bazarr/subtitles/mass_download/movies.py b/bazarr/subtitles/mass_download/movies.py
index 5040022f88..cb367b005f 100644
--- a/bazarr/subtitles/mass_download/movies.py
+++ b/bazarr/subtitles/mass_download/movies.py
@@ -84,6 +84,7 @@ def movies_download_subtitles(no, job_id=None, job_sub_function=False):
providers_list = get_providers()
+ downloaded_count = 0
if providers_list:
for language in ast.literal_eval(movie.missing_subtitles):
if language is not None:
@@ -107,16 +108,21 @@ def movies_download_subtitles(no, job_id=None, job_sub_function=False):
store_subtitles_movie(movie.path, moviePath)
history_log_movie(1, no, result)
send_notifications_movie(no, result.message)
+ downloaded_count += 1
+ outcome_msg = (f"{downloaded_count} subtitle(s) downloaded"
+ if downloaded_count else "No subtitles found")
else:
logging.info("BAZARR All providers are throttled")
+ outcome_msg = "All providers throttled"
- jobs_queue.update_job_progress(job_id=job_id, progress_value="max")
+ jobs_queue.update_job_progress(job_id=job_id, progress_value="max",
+ progress_message=outcome_msg)
jobs_queue.update_job_name(job_id=job_id, new_job_name=f"Downloaded missing subtitles for {movie.title} ({movie.year})")
def movie_download_specific_subtitles(radarr_id, language, hi, forced, job_id=None):
if not job_id:
- return jobs_queue.add_job_from_function("Searching subtitles", progress_max=1, is_progress=False)
+ return jobs_queue.add_job_from_function("Searching subtitles", is_progress=True)
movieInfo = database.execute(
select(
@@ -147,6 +153,7 @@ def movie_download_specific_subtitles(radarr_id, language, hi, forced, job_id=No
language_str = language
jobs_queue.update_job_name(job_id=job_id, new_job_name=f"Searching {language_str.upper()} for {title}")
+ jobs_queue.update_job_progress(job_id=job_id, progress_message="Preparing search...")
audio_language_list = get_audio_profile_languages(movieInfo.audio_language)
if len(audio_language_list) > 0:
@@ -165,7 +172,11 @@ def movie_download_specific_subtitles(radarr_id, language, hi, forced, job_id=No
history_log_movie(1, radarr_id, result)
send_notifications_movie(radarr_id, result.message)
store_subtitles_movie(result.path, moviePath)
+ jobs_queue.update_job_progress(job_id=job_id, progress_value='max',
+ progress_message="Subtitle downloaded")
else:
+ jobs_queue.update_job_progress(job_id=job_id, progress_value='max',
+ progress_message="No subtitles found")
event_stream(type='movie', payload=radarr_id)
return '', 204
except OSError:
diff --git a/bazarr/subtitles/mass_download/series.py b/bazarr/subtitles/mass_download/series.py
index 8e6679f9c1..892035810f 100644
--- a/bazarr/subtitles/mass_download/series.py
+++ b/bazarr/subtitles/mass_download/series.py
@@ -50,6 +50,7 @@ def series_download_subtitles(no, job_id=None, job_sub_function=False):
.join(TableShows)
.where(reduce(operator.and_, conditions))) \
.all()
+ throttled = False
if not episodes_details:
logging.debug(f"BAZARR no episode for that sonarrSeriesId have been found in database or they have all been "
f"ignored because of monitored status, series type or series tags: {no}")
@@ -70,8 +71,12 @@ def series_download_subtitles(no, job_id=None, job_sub_function=False):
else:
jobs_queue.update_job_progress(job_id=job_id, progress_value=count_episodes_details)
logging.info("BAZARR All providers are throttled")
+ throttled = True
break
+ outcome_msg = ("All providers throttled" if throttled
+ else "Search completed")
+ jobs_queue.update_job_progress(job_id=job_id, progress_message=outcome_msg)
jobs_queue.update_job_name(job_id=job_id, new_job_name=f"Downloaded missing subtitles for {series_row.title}")
@@ -126,6 +131,7 @@ def episode_download_subtitles(no, job_id=None, job_sub_function=False, provider
if not providers_list:
providers_list = get_providers()
+ downloaded_count = 0
if providers_list:
audio_language_list = get_audio_profile_languages(episode.audio_language)
if len(audio_language_list) > 0:
@@ -162,17 +168,22 @@ def episode_download_subtitles(no, job_id=None, job_sub_function=False, provider
store_subtitles(episode.path, path_mappings.path_replace(episode.path))
history_log(1, episode.sonarrSeriesId, episode.sonarrEpisodeId, result)
send_notifications(episode.sonarrSeriesId, episode.sonarrEpisodeId, result.message)
+ downloaded_count += 1
+ outcome_msg = (f"{downloaded_count} subtitle(s) downloaded"
+ if downloaded_count else "No subtitles found")
else:
logging.info("BAZARR All providers are throttled")
+ outcome_msg = "All providers throttled"
if not job_sub_function and job_id:
- jobs_queue.update_job_progress(job_id=job_id, progress_value='max')
+ jobs_queue.update_job_progress(job_id=job_id, progress_value='max',
+ progress_message=outcome_msg)
jobs_queue.update_job_name(job_id=job_id, new_job_name=f"Downloaded missing subtitles for {episode.title}")
def episode_download_specific_subtitles(sonarr_series_id, sonarr_episode_id, language, hi, forced, job_id=None):
if not job_id:
- return jobs_queue.add_job_from_function("Searching subtitles", progress_max=1, is_progress=False)
+ return jobs_queue.add_job_from_function("Searching subtitles", is_progress=True)
episodeInfo = database.execute(
select(TableEpisodes.path,
@@ -210,6 +221,7 @@ def episode_download_specific_subtitles(sonarr_series_id, sonarr_episode_id, lan
jobs_queue.update_job_name(job_id=job_id,
new_job_name=f"Searching {language_str.upper()} for {episode_long_title}")
+ jobs_queue.update_job_progress(job_id=job_id, progress_message="Preparing search...")
audio_language_list = get_audio_profile_languages(episodeInfo.audio_language)
if len(audio_language_list) > 0:
@@ -228,7 +240,11 @@ def episode_download_specific_subtitles(sonarr_series_id, sonarr_episode_id, lan
history_log(1, sonarr_series_id, sonarr_episode_id, result)
send_notifications(sonarr_series_id, sonarr_episode_id, result.message)
store_subtitles(result.path, episodePath)
+ jobs_queue.update_job_progress(job_id=job_id, progress_value='max',
+ progress_message="Subtitle downloaded")
else:
+ jobs_queue.update_job_progress(job_id=job_id, progress_value='max',
+ progress_message="No subtitles found")
event_stream(type='episode', payload=sonarr_episode_id)
return '', 204
except OSError:
diff --git a/bazarr/subtitles/mass_operations.py b/bazarr/subtitles/mass_operations.py
new file mode 100644
index 0000000000..d5dda58d85
--- /dev/null
+++ b/bazarr/subtitles/mass_operations.py
@@ -0,0 +1,517 @@
+# coding=utf-8
+
+import ast
+import logging
+import os
+
+from app.config import settings
+from app.database import TableEpisodes, TableMovies, TableHistory, TableHistoryMovie, database, select
+from app.jobs_queue import jobs_queue
+from subtitles.sync import sync_subtitles
+from subtitles.tools.mods import subtitles_apply_mods
+from subtitles.indexer.series import series_scan_subtitles
+from subtitles.indexer.movies import movies_scan_subtitles
+from subtitles.mass_download.series import series_download_subtitles
+from subtitles.mass_download.movies import movies_download_subtitles
+from subtitles.upgrade import upgrade_episodes_subtitles, upgrade_movies_subtitles
+from utilities.path_mappings import path_mappings
+from utilities.video_analyzer import languages_from_colon_seperated_string
+from sqlalchemy import or_
+
+logger = logging.getLogger(__name__)
+
+VALID_ACTIONS = {
+ 'sync', 'translate', 'OCR_fixes', 'common', 'remove_HI',
+ 'remove_tags', 'fix_uppercase', 'reverse_rtl', 'emoji',
+ 'scan-disk', 'search-missing', 'upgrade',
+}
+
+MEDIA_ACTIONS = {'scan-disk', 'search-missing', 'upgrade'}
+
+MOD_ACTIONS = {'OCR_fixes', 'common', 'remove_HI', 'remove_tags', 'fix_uppercase', 'reverse_rtl', 'emoji'}
+
+
+def _parse_subtitles_column(subtitles_raw):
+ """Parse the subtitles TEXT column into a list of (lang_string, path) tuples."""
+ if not subtitles_raw:
+ return []
+ try:
+ parsed = ast.literal_eval(subtitles_raw)
+ return [(entry[0], entry[1]) for entry in parsed if len(entry) >= 2 and entry[1]]
+ except (ValueError, SyntaxError):
+ return []
+
+
+def _get_synced_episode_paths():
+ """Get set of subtitle paths that have been synced (action=5) from episode history."""
+ results = database.execute(
+ select(TableHistory.subtitles_path)
+ .where(TableHistory.action == 5)
+ ).all()
+ return {r.subtitles_path for r in results if r.subtitles_path}
+
+
+def _get_synced_movie_paths():
+ """Get set of subtitle paths that have been synced (action=5) from movie history."""
+ results = database.execute(
+ select(TableHistoryMovie.subtitles_path)
+ .where(TableHistoryMovie.action == 5)
+ ).all()
+ return {r.subtitles_path for r in results if r.subtitles_path}
+
+
+def _collect_subtitle_items(items, action, options):
+ """Collect subtitle items from the database for processing.
+
+ Args:
+ items: List of dicts with 'type' and IDs, or None to collect entire library.
+ action: The action to perform (sync, translate, mod, etc.).
+ options: Dict with force_resync, max_offset_seconds, gss, no_fix_framerate.
+
+ Returns:
+ Tuple of (items_list, skipped_count).
+ """
+ options = options or {}
+ force_resync = options.get('force_resync', False)
+ max_offset = str(options.get('max_offset_seconds', settings.subsync.max_offset_seconds))
+ gss = options.get('gss', settings.subsync.gss)
+ no_fix_framerate = options.get('no_fix_framerate', settings.subsync.no_fix_framerate)
+
+ # Parse item types
+ series_ids = []
+ episode_ids = []
+ movie_ids = []
+
+ if items is None:
+ # Entire library mode
+ pass
+ else:
+ for item in items:
+ item_type = item.get('type')
+ if item_type == 'series':
+ sid = item.get('sonarrSeriesId')
+ if sid is not None:
+ series_ids.append(sid)
+ elif item_type == 'episode':
+ eid = item.get('sonarrEpisodeId')
+ if eid is not None:
+ episode_ids.append(eid)
+ elif item_type == 'movie':
+ rid = item.get('radarrId')
+ if rid is not None:
+ movie_ids.append(rid)
+
+ all_items = []
+ total_skipped = 0
+ target_lang = options.get('to_lang') if action == 'translate' else None
+ source_lang = options.get('from_lang') if action == 'translate' else None
+
+ # Collect episode subtitles
+ should_collect_episodes = (items is None and settings.general.use_sonarr) or series_ids or episode_ids
+ if should_collect_episodes:
+ ep_items, ep_skipped = _collect_episodes(
+ series_ids=series_ids or None,
+ episode_ids=episode_ids or None,
+ action=action,
+ force_resync=force_resync,
+ max_offset=max_offset,
+ gss=gss,
+ no_fix_framerate=no_fix_framerate,
+ target_lang=target_lang,
+ source_lang=source_lang,
+ )
+ all_items.extend(ep_items)
+ total_skipped += ep_skipped
+
+ # Collect movie subtitles
+ should_collect_movies = (items is None and settings.general.use_radarr) or movie_ids
+ if should_collect_movies:
+ mov_items, mov_skipped = _collect_movies(
+ movie_ids=movie_ids or None,
+ action=action,
+ force_resync=force_resync,
+ max_offset=max_offset,
+ gss=gss,
+ no_fix_framerate=no_fix_framerate,
+ target_lang=target_lang,
+ source_lang=source_lang,
+ )
+ all_items.extend(mov_items)
+ total_skipped += mov_skipped
+
+ return all_items, total_skipped
+
+
+def _collect_episodes(series_ids=None, episode_ids=None, action='sync',
+ force_resync=False, max_offset='60', gss=True, no_fix_framerate=True,
+ target_lang=None, source_lang=None):
+ """Collect episode subtitles from the database."""
+ query = select(
+ TableEpisodes.sonarrEpisodeId,
+ TableEpisodes.sonarrSeriesId,
+ TableEpisodes.path,
+ TableEpisodes.subtitles,
+ )
+
+ filters = []
+ if episode_ids:
+ filters.append(TableEpisodes.sonarrEpisodeId.in_(episode_ids))
+ if series_ids:
+ filters.append(TableEpisodes.sonarrSeriesId.in_(series_ids))
+ if filters:
+ query = query.where(or_(*filters))
+
+ episodes = database.execute(query).all()
+
+ synced_paths = set()
+ if action == 'sync' and not force_resync:
+ synced_paths = _get_synced_episode_paths()
+
+ items = []
+ skipped = 0
+
+ for ep in episodes:
+ subtitles = _parse_subtitles_column(ep.subtitles)
+ video_path = path_mappings.path_replace(ep.path)
+
+ # For translate: check if target language already exists
+ if action == 'translate' and target_lang:
+ existing_langs = {lang_str.split(':')[0] for lang_str, _ in subtitles}
+ if target_lang in existing_langs:
+ skipped += 1
+ continue
+
+ for lang_string, sub_path in subtitles:
+ lang_info = languages_from_colon_seperated_string(lang_string)
+
+ # Forced subs can't be synced or translated, but mods are fine
+ if lang_info['forced'] and action in ('sync', 'translate'):
+ skipped += 1
+ continue
+
+ # For translate: only queue subtitles matching the requested source language
+ sub_lang = lang_string.split(':')[0]
+ if action == 'translate' and source_lang and sub_lang != source_lang:
+ skipped += 1
+ continue
+
+ mapped_sub_path = path_mappings.path_replace(sub_path)
+ if not os.path.isfile(mapped_sub_path):
+ skipped += 1
+ continue
+
+ if action == 'sync' and not force_resync:
+ reversed_path = path_mappings.path_replace_reverse(mapped_sub_path)
+ if reversed_path in synced_paths:
+ skipped += 1
+ continue
+
+ items.append({
+ 'video_path': video_path,
+ 'srt_path': mapped_sub_path,
+ 'srt_lang': sub_lang,
+ 'forced': lang_info['forced'],
+ 'hi': lang_info['hi'],
+ 'sonarr_series_id': ep.sonarrSeriesId,
+ 'sonarr_episode_id': ep.sonarrEpisodeId,
+ 'radarr_id': None,
+ 'max_offset_seconds': max_offset,
+ 'no_fix_framerate': no_fix_framerate,
+ 'gss': gss,
+ })
+
+ return items, skipped
+
+
+def _collect_movies(movie_ids=None, action='sync', force_resync=False,
+ max_offset='60', gss=True, no_fix_framerate=True,
+ target_lang=None, source_lang=None):
+ """Collect movie subtitles from the database."""
+ query = select(
+ TableMovies.radarrId,
+ TableMovies.path,
+ TableMovies.subtitles,
+ )
+
+ if movie_ids:
+ query = query.where(TableMovies.radarrId.in_(movie_ids))
+
+ movies = database.execute(query).all()
+
+ synced_paths = set()
+ if action == 'sync' and not force_resync:
+ synced_paths = _get_synced_movie_paths()
+
+ items = []
+ skipped = 0
+
+ for movie in movies:
+ subtitles = _parse_subtitles_column(movie.subtitles)
+ video_path = path_mappings.path_replace_movie(movie.path)
+
+ # For translate: check if target language already exists
+ if action == 'translate' and target_lang:
+ existing_langs = {lang_str.split(':')[0] for lang_str, _ in subtitles}
+ if target_lang in existing_langs:
+ skipped += 1
+ continue
+
+ for lang_string, sub_path in subtitles:
+ lang_info = languages_from_colon_seperated_string(lang_string)
+
+ # Forced subs can't be synced or translated, but mods are fine
+ if lang_info['forced'] and action in ('sync', 'translate'):
+ skipped += 1
+ continue
+
+ # For translate: only queue subtitles matching the requested source language
+ sub_lang = lang_string.split(':')[0]
+ if action == 'translate' and source_lang and sub_lang != source_lang:
+ skipped += 1
+ continue
+
+ mapped_sub_path = path_mappings.path_replace_movie(sub_path)
+ if not os.path.isfile(mapped_sub_path):
+ skipped += 1
+ continue
+
+ if action == 'sync' and not force_resync:
+ reversed_path = path_mappings.path_replace_reverse_movie(mapped_sub_path)
+ if reversed_path in synced_paths:
+ skipped += 1
+ continue
+
+ items.append({
+ 'video_path': video_path,
+ 'srt_path': mapped_sub_path,
+ 'srt_lang': sub_lang,
+ 'forced': lang_info['forced'],
+ 'hi': lang_info['hi'],
+ 'sonarr_series_id': None,
+ 'sonarr_episode_id': None,
+ 'radarr_id': movie.radarrId,
+ 'max_offset_seconds': max_offset,
+ 'no_fix_framerate': no_fix_framerate,
+ 'gss': gss,
+ })
+
+ return items, skipped
+
+
+def _process_subtitle_item(item, action, options, job_id):
+ """Process a single subtitle item based on the action.
+
+ Returns True on success, False on failure.
+ """
+ if action == 'sync':
+ return sync_subtitles(
+ video_path=item['video_path'],
+ srt_path=item['srt_path'],
+ srt_lang=item['srt_lang'],
+ forced=item['forced'],
+ hi=item['hi'],
+ percent_score=0,
+ sonarr_series_id=item['sonarr_series_id'],
+ sonarr_episode_id=item['sonarr_episode_id'],
+ radarr_id=item['radarr_id'],
+ max_offset_seconds=item['max_offset_seconds'],
+ no_fix_framerate=item['no_fix_framerate'],
+ gss=item['gss'],
+ force_sync=True,
+ job_id=job_id,
+ )
+ elif action == 'translate':
+ from subtitles.tools.translate.main import translate_subtitles_file
+ media_type = 'series' if item['sonarr_series_id'] else 'movies'
+ # Don't pass the batch job_id to translate. translate_subtitles_file
+ # has its own job/progress lifecycle that would hijack the batch job.
+ # Calling without job_id makes it queue as its own separate job.
+ translate_subtitles_file(
+ video_path=item['video_path'],
+ source_srt_file=item['srt_path'],
+ from_lang=options.get('from_lang', item['srt_lang']),
+ to_lang=options.get('to_lang', 'en'),
+ forced=item['forced'],
+ hi=item['hi'],
+ media_type=media_type,
+ sonarr_series_id=item['sonarr_series_id'],
+ sonarr_episode_id=item['sonarr_episode_id'],
+ radarr_id=item['radarr_id'],
+ )
+ return True
+ elif action in MOD_ACTIONS:
+ subtitles_apply_mods(
+ item['srt_lang'],
+ item['srt_path'],
+ [action],
+ item['video_path'],
+ )
+ return True
+ return False
+
+
+def _process_media_action(items, action, job_id):
+ """Handle scan-disk, search-missing, and upgrade actions for series/movies.
+
+ Args:
+ items: List of dicts with 'type' and IDs.
+ action: 'scan-disk', 'search-missing', or 'upgrade'.
+ job_id: Job ID for progress tracking.
+
+ Returns:
+ Dict with queued, skipped, errors.
+ """
+ queued = 0
+ skipped = 0
+ errors = []
+
+ if action == 'upgrade':
+ sonarr_series_ids = [i.get('sonarrSeriesId') for i in items
+ if i.get('type') in ('series', 'episode') and i.get('sonarrSeriesId')]
+ radarr_ids = [i.get('radarrId') for i in items
+ if i.get('type') == 'movie' and i.get('radarrId')]
+ try:
+ if sonarr_series_ids:
+ upgrade_episodes_subtitles(job_id=job_id, sonarr_series_ids=sonarr_series_ids)
+ if radarr_ids:
+ upgrade_movies_subtitles(job_id=job_id, radarr_ids=radarr_ids)
+ queued = len(sonarr_series_ids) + len(radarr_ids)
+ except Exception as e:
+ logger.error(f'Error during upgrade: {e}')
+ errors.append(str(e))
+ return {'queued': queued, 'skipped': 0, 'errors': errors}
+
+ jobs_queue.update_job_progress(job_id=job_id, progress_max=len(items))
+
+ for i, item in enumerate(items, start=1):
+ item_type = item.get('type')
+ jobs_queue.update_job_progress(
+ job_id=job_id,
+ progress_value=i,
+ progress_message=f"Processing {item_type} ({i}/{len(items)})"
+ )
+
+ try:
+ if action == 'scan-disk':
+ if item_type in ('series', 'episode'):
+ series_id = item.get('sonarrSeriesId')
+ if not series_id:
+ skipped += 1
+ continue
+ series_scan_subtitles(series_id)
+ elif item_type == 'movie':
+ radarr_id = item.get('radarrId')
+ if not radarr_id:
+ skipped += 1
+ continue
+ movies_scan_subtitles(radarr_id)
+ else:
+ skipped += 1
+ continue
+ elif action == 'search-missing':
+ if item_type in ('series', 'episode'):
+ series_id = item.get('sonarrSeriesId')
+ if not series_id:
+ skipped += 1
+ continue
+ series_download_subtitles(series_id)
+ elif item_type == 'movie':
+ radarr_id = item.get('radarrId')
+ if not radarr_id:
+ skipped += 1
+ continue
+ movies_download_subtitles(radarr_id)
+ else:
+ skipped += 1
+ continue
+ queued += 1
+ except Exception as e:
+ logger.error(f'Error processing {action} for {item}: {e}')
+ errors.append(str(e))
+
+ return {'queued': queued, 'skipped': skipped, 'errors': errors}
+
+
+def mass_batch_operation(items=None, action='sync', options=None, job_id=None):
+ """Main entry point for all batch operations on subtitles.
+
+ Handles sync, translate, subtitle mods, scan-disk, and search-missing
+ in a unified interface. Runs as a single job with progress tracking,
+ processing items sequentially.
+
+ Args:
+ items: List of dicts with 'type' and IDs. If None, processes entire library.
+ action: One of VALID_ACTIONS.
+ options: Dict with action-specific options (force_resync, from_lang, to_lang, etc.).
+ job_id: Job ID for scheduled task tracking.
+
+ Returns:
+ Dict with queued, skipped, errors. Or None if scheduling a job.
+ """
+ if action not in VALID_ACTIONS:
+ return {'queued': 0, 'skipped': 0, 'errors': [f'Invalid action: {action}']}
+
+ options = options or {}
+
+ # When called without a job_id (e.g. from the scheduler), create one so that
+ # downstream functions like sync_subtitles run inline instead of re-queuing
+ # themselves as individual jobs.
+ if not job_id:
+ jobs_queue.add_job_from_function(
+ f"Mass {action.replace('_', ' ').replace('-', ' ').title()} "
+ f"({'Library' if items is None else f'{len(items)} items'})",
+ is_progress=True,
+ )
+ return
+
+ # Media actions (scan-disk, search-missing) work on media items directly
+ if action in MEDIA_ACTIONS:
+ if not items:
+ return {'queued': 0, 'skipped': 0, 'errors': []}
+ return _process_media_action(items, action, job_id)
+
+ # Subtitle actions: collect subtitle files, then process them
+ if items is not None and len(items) == 0:
+ jobs_queue.update_job_progress(job_id=job_id, progress_max=0)
+ return {'queued': 0, 'skipped': 0, 'errors': []}
+
+ all_items, total_skipped = _collect_subtitle_items(items, action, options)
+
+ # Process items sequentially within this single job
+ total_count = len(all_items)
+ jobs_queue.update_job_progress(job_id=job_id, progress_max=total_count)
+
+ if total_count == 0:
+ jobs_queue.update_job_progress(job_id=job_id, progress_value='max')
+
+ processed = 0
+ failed = 0
+ all_errors = []
+
+ for i, item in enumerate(all_items, start=1):
+ jobs_queue.update_job_progress(
+ job_id=job_id,
+ progress_value=i,
+ progress_message=f"{action}: {os.path.basename(item['srt_path'])} ({i}/{total_count})"
+ )
+
+ try:
+ result = _process_subtitle_item(item, action, options, job_id)
+ if result:
+ processed += 1
+ else:
+ failed += 1
+ except Exception as e:
+ logger.error(f'Error during {action} on {item["srt_path"]}: {e}')
+ all_errors.append(str(e))
+ failed += 1
+
+ jobs_queue.update_job_name(
+ job_id=job_id,
+ new_job_name=f"Mass {action} complete: {processed} done, {total_skipped} skipped"
+ )
+ logger.info(
+ f'BAZARR mass {action} complete: {processed} processed, {failed} failed, '
+ f'{total_skipped} skipped, {len(all_errors)} errors'
+ )
+ return {'queued': processed, 'skipped': total_skipped + failed, 'errors': all_errors}
diff --git a/bazarr/subtitles/sync.py b/bazarr/subtitles/sync.py
index ca098d6dbf..ef14039430 100644
--- a/bazarr/subtitles/sync.py
+++ b/bazarr/subtitles/sync.py
@@ -23,7 +23,8 @@ def sync_subtitles(video_path,
gss=settings.subsync.gss,
no_fix_framerate=settings.subsync.no_fix_framerate,
reference=None,
- force_sync=False):
+ force_sync=False,
+ callback=None):
if not settings.subsync.use_subsync and not force_sync:
logging.debug('BAZARR automatic syncing is disabled in settings. Skipping sync routine.')
return False
@@ -66,6 +67,8 @@ def sync_subtitles(video_path,
}
try:
subsync.sync(**sync_kwargs)
+ if callback:
+ callback()
except Exception:
logging.exception(f'BAZARR an unhandled exception occurs during the synchronization process for this '
f'subtitle file: {srt_path}')
diff --git a/bazarr/subtitles/tools/mods.py b/bazarr/subtitles/tools/mods.py
index 8d0536d0fb..302b816a18 100644
--- a/bazarr/subtitles/tools/mods.py
+++ b/bazarr/subtitles/tools/mods.py
@@ -1,6 +1,7 @@
# coding=utf-8
import os
+import sys
import logging
from subliminal_patch.subtitle import Subtitle
@@ -8,11 +9,93 @@
from subzero.language import Language
from app.config import settings
+from app.jobs_queue import jobs_queue
from languages.custom_lang import CustomLanguage
from languages.get_languages import alpha3_from_alpha2
from subtitles.indexer.utils import get_external_subtitles_path
+MOD_LABELS = {
+ 'remove_HI': 'Remove HI Tags',
+ 'remove_tags': 'Remove Style Tags',
+ 'OCR_fixes': 'OCR Fixes',
+ 'common': 'Common Fixes',
+ 'fix_uppercase': 'Fix Uppercase',
+ 'reverse_rtl': 'Reverse RTL',
+ 'add_color': 'Add Color',
+ 'change_frame_rate': 'Change Frame Rate',
+ 'adjust_time': 'Adjust Times',
+ 'emoji': 'Remove Emoji',
+}
+
+
+def apply_subtitle_mods(language, subtitle_path, mods, video_path,
+ media_type=None, media_id=None, job_id=None):
+ """Job-aware wrapper for subtitles_apply_mods.
+
+ When called without a job_id, queues the work as a backend job and returns
+ immediately. When called with a job_id (by the job queue consumer), does the
+ actual work and handles post-processing (store_subtitles, event_stream, chmod).
+ """
+ if not job_id:
+ # No local variables can be assigned before add_job_from_function because
+ # it introspects the frame and re-passes all locals as kwargs on re-invocation.
+ jobs_queue.add_job_from_function(
+ (lambda m, p: f'{MOD_LABELS.get(m, m)}: {os.path.basename(p)}')(
+ mods[0] if mods else 'mods', subtitle_path),
+ is_progress=False,
+ )
+ return
+
+ mod_label = MOD_LABELS.get(mods[0], mods[0]) if mods else 'Apply Mods'
+ filename = os.path.basename(subtitle_path)
+
+ try:
+ subtitles_apply_mods(language=language, subtitle_path=subtitle_path,
+ mods=mods, video_path=video_path)
+ except Exception:
+ jobs_queue.update_job_name(
+ job_id=job_id,
+ new_job_name=f'Failed {mod_label}: {filename}',
+ )
+ raise
+
+ # apply chmod if required
+ chmod = int(settings.general.chmod, 8) if not sys.platform.startswith(
+ 'win') and settings.general.chmod_enabled else None
+ if chmod and os.path.exists(subtitle_path):
+ os.chmod(subtitle_path, chmod)
+
+ # re-index subtitles so Bazarr's DB picks up the changes
+ from subtitles.indexer.series import store_subtitles
+ from subtitles.indexer.movies import store_subtitles_movie
+ from app.event_handler import event_stream
+ from utilities.path_mappings import path_mappings
+
+ if media_type == 'episode':
+ store_subtitles(path_mappings.path_replace_reverse(video_path), video_path)
+ elif media_type == 'movie':
+ store_subtitles_movie(path_mappings.path_replace_reverse_movie(video_path), video_path)
+
+ if media_id and media_type:
+ if media_type == 'episode':
+ from app.database import TableEpisodes, database, select
+ metadata = database.execute(
+ select(TableEpisodes.sonarrSeriesId)
+ .where(TableEpisodes.sonarrEpisodeId == media_id)
+ ).first()
+ if metadata:
+ event_stream(type='series', payload=metadata.sonarrSeriesId)
+ event_stream(type='episode', payload=media_id)
+ else:
+ event_stream(type='movie', payload=media_id)
+
+ jobs_queue.update_job_name(
+ job_id=job_id,
+ new_job_name=f'{mod_label}: {filename}',
+ )
+
+
def subtitles_apply_mods(language, subtitle_path, mods, video_path):
language = alpha3_from_alpha2(language)
custom = CustomLanguage.from_value(language, "alpha3")
diff --git a/bazarr/subtitles/tools/score.py b/bazarr/subtitles/tools/score.py
deleted file mode 100644
index 11771354ef..0000000000
--- a/bazarr/subtitles/tools/score.py
+++ /dev/null
@@ -1,140 +0,0 @@
-# coding=utf-8
-
-from __future__ import annotations
-
-import logging
-
-from app.config import get_settings
-
-logger = logging.getLogger(__name__)
-
-
-class Score:
- media = None
- defaults = {}
-
- def __init__(self, load_profiles=False, **kwargs):
- self.data = self.defaults.copy()
- self.data.update(**kwargs)
- self.data["hash"] = self._hash_score()
- self._profiles = []
- self._profiles_loaded = False
-
- if load_profiles:
- self.load_profiles()
-
- def check_custom_profiles(self, subtitle, matches):
- if not self._profiles_loaded:
- self.load_profiles()
-
- for profile in self._profiles:
- if profile.check(subtitle):
- matches.add(profile.name)
-
- def load_profiles(self):
- self._profiles = []
-
- if self._profiles:
- logger.debug("Loaded profiles: %s", self._profiles)
- else:
- logger.debug("No score profiles found")
- self._profiles = []
-
- self._profiles_loaded = True
-
- def reset(self):
- self.data.update(self.defaults)
-
- def update(self, **kwargs):
- self.data.update(kwargs)
-
- @classmethod
- def from_config(cls, **kwargs):
- return cls(True, **kwargs)
-
- def get_scores(self, min_percent, special=None):
- return (
- self.max_score * (special or min_percent) / 100,
- self.max_score,
- set(list(self.scores.keys())),
- )
-
- @property
- def custom_profile_scores(self):
- return {item.name: item.score for item in self._profiles}
-
- @property
- def scores(self):
- return {**self.custom_profile_scores, **self.data}
-
- @property
- def max_score(self):
- return (
- self.data["hash"]
- + self.data["hearing_impaired"]
- + sum(item.score for item in self._profiles if item.score)
- )
-
- def _hash_score(self):
- return sum(
- val
- for key, val in self.data.items()
- if key not in ("hash", "hearing_impaired")
- )
-
- def __str__(self):
- return f"<{self.media} Score class>"
-
-
-class SeriesScore(Score):
- media = "series"
- defaults = {
- "hash": 359,
- "series": 180,
- "year": 90,
- "season": 30,
- "episode": 30,
- "release_group": 15,
- "source": 7,
- "audio_codec": 3,
- "resolution": 2,
- "video_codec": 2,
- "hearing_impaired": 1,
- "streaming_service": 0,
- "edition": 0,
- }
-
- @classmethod
- def from_config(cls, **kwargs):
- return cls(True, **kwargs["series_scores"])
-
- def update(self, **kwargs):
- self.data.update(kwargs["series_scores"])
-
-
-class MovieScore(Score):
- media = "movies"
- defaults = {
- "hash": 119,
- "title": 60,
- "year": 30,
- "release_group": 15,
- "source": 7,
- "audio_codec": 3,
- "resolution": 2,
- "video_codec": 2,
- "hearing_impaired": 1,
- "streaming_service": 0,
- "edition": 0,
- }
-
- @classmethod
- def from_config(cls, **kwargs):
- return cls(True, **kwargs["movie_scores"])
-
- def update(self, **kwargs):
- self.data.update(kwargs["movie_scores"])
-
-
-series_score = SeriesScore.from_config(**get_settings())
-movie_score = MovieScore.from_config(**get_settings())
diff --git a/bazarr/subtitles/tools/translate/batch.py b/bazarr/subtitles/tools/translate/batch.py
index 7290de716e..b80feaa818 100644
--- a/bazarr/subtitles/tools/translate/batch.py
+++ b/bazarr/subtitles/tools/translate/batch.py
@@ -4,9 +4,14 @@
import os
import ast
import re
-from app.database import TableEpisodes, TableMovies, database, select
+import subprocess
+from app.database import TableEpisodes, TableMovies, TableShows, database, select
+from app.jobs_queue import jobs_queue
from app.event_handler import event_stream
from utilities.path_mappings import path_mappings
+from utilities.binaries import get_binary
+from utilities.video_analyzer import parse_video_metadata, _handle_alpha3
+from languages.get_languages import alpha3_from_alpha2
from subtitles.indexer.series import store_subtitles
from subtitles.indexer.movies import store_subtitles_movie
from subtitles.tools.translate.main import translate_subtitles_file
@@ -24,7 +29,8 @@ def process_episode_translation(item, source_language, target_language, forced,
# Get episode info from database
episode = database.execute(
- select(TableEpisodes.path, TableEpisodes.subtitles, TableEpisodes.sonarrSeriesId)
+ select(TableEpisodes.path, TableEpisodes.subtitles, TableEpisodes.sonarrSeriesId,
+ TableEpisodes.title, TableEpisodes.season, TableEpisodes.episode)
.where(TableEpisodes.sonarrEpisodeId == sonarr_episode_id)
).first()
@@ -32,6 +38,20 @@ def process_episode_translation(item, source_language, target_language, forced,
logger.error(f'Episode {sonarr_episode_id} not found')
return False
+ # Get series title
+ show = database.execute(
+ select(TableShows.title).where(TableShows.sonarrSeriesId == sonarr_series_id)
+ ).first()
+ show_title = show.title if show else 'Unknown'
+
+ # Update job name with actual episode info
+ if job_id:
+ ep_label = f'{show_title} S{episode.season:02d}E{episode.episode:02d}'
+ jobs_queue.update_job_name(
+ job_id=job_id,
+ new_job_name=f'Translating {ep_label} ({source_language.upper()} โ {target_language.upper()})'
+ )
+
video_path = path_mappings.path_replace(episode.path)
# Find source subtitle
@@ -52,7 +72,7 @@ def process_episode_translation(item, source_language, target_language, forced,
# Queue translation
try:
- result = translate_subtitles_file(
+ translate_subtitles_file(
video_path=video_path,
source_srt_file=source_subtitle_path,
from_lang=source_language,
@@ -65,14 +85,12 @@ def process_episode_translation(item, source_language, target_language, forced,
radarr_id=None,
job_id=job_id
)
- if result is not False:
- # Re-index subtitles so Bazarr's DB knows about the new translated file
- store_subtitles(path_mappings.path_replace_reverse(video_path), video_path)
- # Notify frontend to refresh
- event_stream(type='series', payload=sonarr_series_id)
- event_stream(type='episode', payload=sonarr_episode_id)
- return True
- return False
+ # Re-index subtitles so Bazarr's DB knows about the new translated file
+ store_subtitles(path_mappings.path_replace_reverse(video_path), video_path)
+ # Notify frontend to refresh series and episode views
+ event_stream(type='series', payload=sonarr_series_id)
+ event_stream(type='episode', payload=sonarr_episode_id)
+ return True
except Exception as e:
logger.error(f'Translation failed for episode {sonarr_episode_id}: {e}')
return False
@@ -87,7 +105,7 @@ def process_movie_translation(item, source_language, target_language, forced, hi
# Get movie info from database
movie = database.execute(
- select(TableMovies.path, TableMovies.subtitles)
+ select(TableMovies.path, TableMovies.subtitles, TableMovies.title)
.where(TableMovies.radarrId == radarr_id)
).first()
@@ -95,6 +113,13 @@ def process_movie_translation(item, source_language, target_language, forced, hi
logger.error(f'Movie {radarr_id} not found')
return False
+ # Update job name with actual movie title
+ if job_id:
+ jobs_queue.update_job_name(
+ job_id=job_id,
+ new_job_name=f'Translating {movie.title} ({source_language.upper()} โ {target_language.upper()})'
+ )
+
video_path = path_mappings.path_replace_movie(movie.path)
# Find source subtitle
@@ -115,7 +140,7 @@ def process_movie_translation(item, source_language, target_language, forced, hi
# Queue translation
try:
- result = translate_subtitles_file(
+ translate_subtitles_file(
video_path=video_path,
source_srt_file=source_subtitle_path,
from_lang=source_language,
@@ -128,13 +153,11 @@ def process_movie_translation(item, source_language, target_language, forced, hi
radarr_id=radarr_id,
job_id=job_id
)
- if result is not False:
- # Re-index subtitles so Bazarr's DB knows about the new translated file
- store_subtitles_movie(path_mappings.path_replace_reverse_movie(video_path), video_path)
- # Notify frontend to refresh
- event_stream(type='movie', payload=radarr_id)
- return True
- return False
+ # Re-index subtitles so Bazarr's DB knows about the new translated file
+ store_subtitles_movie(path_mappings.path_replace_reverse_movie(video_path), video_path)
+ # Notify frontend to refresh movie view
+ event_stream(type='movie', payload=radarr_id)
+ return True
except Exception as e:
logger.error(f'Translation failed for movie {radarr_id}: {e}')
return False
@@ -215,21 +238,173 @@ def sort_key(sub):
if resolved_path:
return resolved_path, sub['code2']
- # Third pass: Scan filesystem fallback
+ # Third pass: Extract embedded subtitles from video container
+ if subtitles and isinstance(subtitles, list):
+ embedded_subs = []
+ for sub in subtitles:
+ if isinstance(sub, (list, tuple)) and len(sub) >= 2:
+ lang_parts = sub[0].split(':')
+ sub_code = lang_parts[0]
+ sub_path = sub[1]
+ if sub_path is None: # Embedded subtitle (no file on disk)
+ embedded_subs.append({
+ 'code2': sub_code,
+ 'hi': len(lang_parts) > 1 and lang_parts[1].lower() == 'hi',
+ 'forced': len(lang_parts) > 1 and lang_parts[1].lower() == 'forced',
+ })
+
+ if embedded_subs:
+ # Prefer exact language match, then fall back to any (preferring English)
+ candidates = [s for s in embedded_subs if s['code2'] == language_code]
+ if not candidates:
+ common_languages = ['en', 'eng']
+ candidates = sorted(embedded_subs,
+ key=lambda x: (x['forced'], x['hi'], x['code2'] not in common_languages))
+
+ for sub in candidates:
+ extracted_path = extract_embedded_subtitle(video_path, sub['code2'], media_type)
+ if extracted_path:
+ return extracted_path, sub['code2']
+
+ # Fourth pass: Scan filesystem fallback
filesystem_subs = scan_filesystem_for_subtitles(video_path)
-
+
if filesystem_subs:
# Prefer English
for sub in filesystem_subs:
if sub['is_english']:
return sub['path'], 'en'
-
+
# Use first available
sub = filesystem_subs[0]
return sub['path'], sub['detected_language']
-
+
return None, None
+def extract_embedded_subtitle(video_path, language_code2, media_type):
+ """Extract an embedded subtitle track from a video file using ffmpeg.
+
+ Returns the path to the extracted .srt file, or None on failure.
+ """
+ target_alpha3 = alpha3_from_alpha2(language_code2)
+ if not target_alpha3:
+ logger.error(f'Cannot convert language code {language_code2} to alpha3')
+ return None
+
+ # Look up file metadata needed by parse_video_metadata
+ if media_type == 'series':
+ db_path = path_mappings.path_replace_reverse(video_path)
+ media = database.execute(
+ select(TableEpisodes.episode_file_id, TableEpisodes.file_size)
+ .where(TableEpisodes.path == db_path)
+ ).first()
+ if not media:
+ return None
+ data = parse_video_metadata(video_path, media.file_size,
+ episode_file_id=media.episode_file_id)
+ else:
+ db_path = path_mappings.path_replace_reverse_movie(video_path)
+ media = database.execute(
+ select(TableMovies.movie_file_id, TableMovies.file_size)
+ .where(TableMovies.path == db_path)
+ ).first()
+ if not media:
+ return None
+ data = parse_video_metadata(video_path, media.file_size,
+ movie_file_id=media.movie_file_id)
+
+ if not data:
+ return None
+
+ # Find the subtitle provider (ffprobe or mediainfo)
+ cache_provider = None
+ if data.get("ffprobe") and "subtitle" in data["ffprobe"]:
+ cache_provider = 'ffprobe'
+ elif data.get("mediainfo") and "subtitle" in data["mediainfo"]:
+ cache_provider = 'mediainfo'
+ if not cache_provider:
+ return None
+
+ # Bitmap-based subtitle codecs that cannot be converted to SRT
+ bitmap_codecs = ['pgs', 'vobsub', 'dvd_subtitle', 'dvbsub', 'hdmv_pgs_subtitle', 'dvd']
+
+ # Find the matching subtitle stream index
+ track_id = 0
+ found_track = None
+ for track in data[cache_provider]["subtitle"]:
+ codec = (track.get("format") or track.get("name") or "").lower()
+ if any(bc in codec for bc in bitmap_codecs):
+ track_id += 1
+ continue
+
+ if "language" not in track:
+ track_id += 1
+ continue
+
+ track_alpha3 = _handle_alpha3(track)
+ if track_alpha3 == target_alpha3:
+ found_track = track_id
+ break
+ track_id += 1
+
+ if found_track is None:
+ logger.debug(f'No extractable embedded subtitle found for language {language_code2} in {video_path}')
+ return None
+
+ # Build output path in Bazarr's config dir so Jellyfin won't pick it up
+ import hashlib
+ extract_dir = os.path.join('/config', 'extracted_subs')
+ os.makedirs(extract_dir, exist_ok=True)
+ video_hash = hashlib.md5(video_path.encode()).hexdigest()
+ output_path = os.path.join(extract_dir, f"{video_hash}.{language_code2}.srt")
+
+ # Skip extraction if already done
+ if os.path.exists(output_path) and os.path.getsize(output_path) > 0:
+ logger.debug(f'Using previously extracted subtitle: {output_path}')
+ return output_path
+
+ # Extract using ffmpeg
+ try:
+ ffmpeg_path = get_binary("ffmpeg")
+ except Exception:
+ logger.error("ffmpeg binary not found, cannot extract embedded subtitles")
+ return None
+
+ cmd = [ffmpeg_path, '-y', '-loglevel', 'error',
+ '-i', video_path,
+ '-map', f'0:s:{found_track}',
+ '-c:s', 'srt',
+ output_path]
+
+ try:
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
+ if result.returncode != 0:
+ logger.error(f'ffmpeg extraction failed for {video_path}: {result.stderr}')
+ if os.path.exists(output_path):
+ os.remove(output_path)
+ return None
+ if os.path.exists(output_path) and os.path.getsize(output_path) > 0:
+ # Strip Windows carriage returns (\r) that ffmpeg may produce
+ with open(output_path, 'r', encoding='utf-8-sig', errors='replace') as f:
+ content = f.read()
+ if '\r' in content:
+ with open(output_path, 'w', encoding='utf-8') as f:
+ f.write(content.replace('\r', ''))
+ logger.info(f'Extracted embedded {language_code2} subtitle to: {output_path}')
+ return output_path
+ return None
+ except subprocess.TimeoutExpired:
+ logger.error(f'ffmpeg extraction timed out for {video_path}')
+ if os.path.exists(output_path):
+ os.remove(output_path)
+ return None
+ except Exception as e:
+ logger.error(f'Failed to extract embedded subtitle: {e}')
+ if os.path.exists(output_path):
+ os.remove(output_path)
+ return None
+
+
def scan_filesystem_for_subtitles(video_path):
"""Scan filesystem for .srt files next to the video file."""
ENGLISH_PATTERNS = [
diff --git a/bazarr/subtitles/tools/translate/core/translator_utils.py b/bazarr/subtitles/tools/translate/core/translator_utils.py
index 2ac5700b73..e74ef31647 100644
--- a/bazarr/subtitles/tools/translate/core/translator_utils.py
+++ b/bazarr/subtitles/tools/translate/core/translator_utils.py
@@ -9,6 +9,7 @@
from app.config import settings
from subzero.language import Language
+from subliminal_patch.score import MAX_SCORES
from languages.custom_lang import CustomLanguage
from languages.get_languages import alpha3_from_alpha2, language_from_alpha2, language_from_alpha3
from subtitles.processing import ProcessSubtitlesResult
@@ -56,10 +57,10 @@ def create_process_result(message, video_path, orig_to_lang, forced, hi, dest_sr
"""Create a ProcessSubtitlesResult object with common parameters."""
if media_type == 'series':
prr = path_mappings.path_replace_reverse
- score = int((settings.translator.default_score / 100) * 360)
+ score = int((settings.translator.default_score / 100) * MAX_SCORES['episode'])
else:
prr = path_mappings.path_replace_reverse_movie
- score = int((settings.translator.default_score / 100) * 120)
+ score = int((settings.translator.default_score / 100) * MAX_SCORES['movie'])
return ProcessSubtitlesResult(
message=message,
diff --git a/bazarr/subtitles/tools/translate/main.py b/bazarr/subtitles/tools/translate/main.py
index 8201b47187..4c20c6fad5 100644
--- a/bazarr/subtitles/tools/translate/main.py
+++ b/bazarr/subtitles/tools/translate/main.py
@@ -3,10 +3,11 @@
import logging
import os
+
from subliminal_patch.core import get_subtitle_path
from subzero.language import Language
-from .core.translator_utils import validate_translation_params, convert_language_codes
+from .core.translator_utils import validate_translation_params, convert_language_codes, get_title
from .services.translator_factory import TranslatorFactory
from languages.get_languages import alpha3_from_alpha2
from app.config import settings
@@ -15,13 +16,19 @@
def translate_subtitles_file(video_path, source_srt_file, from_lang, to_lang, forced, hi,
- media_type, sonarr_series_id, sonarr_episode_id, radarr_id, job_id=None):
+ media_type, sonarr_series_id, sonarr_episode_id, radarr_id, metadata, job_id=None):
if not job_id:
- jobs_queue.add_job_from_function(f'Translating from {from_lang.upper()} to {to_lang.upper()} using '
- f'{settings.translator.translator_type.replace("_", " ").title()}',
- is_progress=True)
+ # Build job label with media title. Note: no local variables can be
+ # assigned here because add_job_from_function introspects the frame
+ # and re-passes all locals as kwargs on re-invocation.
+ jobs_queue.add_job_from_function(
+ (lambda t: f'Translating {t} ({from_lang.upper()} to {to_lang.upper()})' if t else
+ f'Translating {from_lang.upper()} to {to_lang.upper()}')(
+ get_title(media_type, radarr_id, sonarr_series_id, sonarr_episode_id)),
+ is_progress=True)
return
+ translator_label = settings.translator.translator_type.replace("_", " ").title()
try:
logging.debug(f'Translation request: video={video_path}, source={source_srt_file}, from={from_lang}, to={to_lang}')
@@ -30,7 +37,6 @@ def translate_subtitles_file(video_path, source_srt_file, from_lang, to_lang, fo
logging.debug(f'BAZARR is translating in {lang_obj} this subtitles {source_srt_file}')
- # get the destination path if the subtitles are alongside the video
dest_srt_file_if_alongside_video = get_subtitle_path(
video_path,
language=lang_obj if isinstance(lang_obj, Language) else lang_obj.subzero_language(),
@@ -39,8 +45,6 @@ def translate_subtitles_file(video_path, source_srt_file, from_lang, to_lang, fo
hi_tag=hi
)
- # get the real destination path taking into account if the user set up Bazarr to store external subtitles in
- # a custom folder or relative folder
dest_srt_file = get_external_subtitles_path(
file=video_path,
subtitle=os.path.basename(dest_srt_file_if_alongside_video)
@@ -68,14 +72,29 @@ def translate_subtitles_file(video_path, source_srt_file, from_lang, to_lang, fo
logging.debug(f'Created translator instance: {translator.__class__.__name__}')
result = translator.translate(job_id=job_id)
+ if result is False:
+ raise RuntimeError(f'{translator.__class__.__name__} returned a failed translation result')
logging.debug(f'BAZARR saved translated subtitles to {dest_srt_file}')
+
+ from api.subtitles.subtitles import postprocess_subtitles
+ # Call postprocess_subtitles after translation (handles chmod, re-indexing, events)
+ postprocess_subtitles(dest_srt_file, video_path, media_type, metadata, sonarr_episode_id if media_type == 'series' else radarr_id)
+
+ # Get current job name (which batch.py already set with title) and mark as done
+ current_name = jobs_queue.get_job_name(job_id)
+ if current_name and 'Translating' in current_name:
+ done_name = current_name.replace('Translating', 'Translated')
+ else:
+ done_name = f'Translated {from_lang.upper()} \u2192 {to_lang.upper()} using {translator_label}'
+ jobs_queue.update_job_name(job_id=job_id, new_job_name=done_name)
return result
except Exception as e:
logging.error(f'Translation failed: {str(e)}', exc_info=True)
- return False
-
- finally:
- jobs_queue.update_job_name(job_id=job_id,
- new_job_name=f'Translated from {from_lang.upper()} to {to_lang.upper()} using '
- f'{settings.translator.translator_type.replace("_", " ").title()}')
+ current_name = jobs_queue.get_job_name(job_id)
+ if current_name and 'Translating' in current_name:
+ fail_name = current_name.replace('Translating', 'Failed')
+ else:
+ fail_name = f'Failed: {from_lang.upper()} โ {to_lang.upper()} using {translator_label}'
+ jobs_queue.update_job_name(job_id=job_id, new_job_name=fail_name)
+ raise
diff --git a/bazarr/subtitles/tools/translate/services/auth.py b/bazarr/subtitles/tools/translate/services/auth.py
new file mode 100644
index 0000000000..31cfac4468
--- /dev/null
+++ b/bazarr/subtitles/tools/translate/services/auth.py
@@ -0,0 +1,24 @@
+import hashlib
+import hmac
+
+from app.config import settings
+
+AUTH_MESSAGE = b"subtitle-translator-auth-v1"
+
+
+def get_translator_auth_headers(encryption_key=None):
+ """Build auth headers for requests to the AI Subtitle Translator.
+
+ Computes HMAC-SHA256(encryption_key, "subtitle-translator-auth-v1")
+ and returns it as X-Auth-Token. Returns empty dict when no key is
+ configured (backwards-compatible).
+ """
+ key = encryption_key or settings.translator.openrouter_encryption_key
+ if not key:
+ return {}
+ token = hmac.new(
+ bytes.fromhex(key),
+ AUTH_MESSAGE,
+ hashlib.sha256,
+ ).hexdigest()
+ return {"X-Auth-Token": token}
diff --git a/bazarr/subtitles/tools/translate/services/encryption.py b/bazarr/subtitles/tools/translate/services/encryption.py
new file mode 100644
index 0000000000..2b56ba8b52
--- /dev/null
+++ b/bazarr/subtitles/tools/translate/services/encryption.py
@@ -0,0 +1,36 @@
+import base64
+import os
+import re
+
+from cryptography.hazmat.primitives.ciphers.aead import AESGCM
+
+_HEX_64_RE = re.compile(r'^[0-9a-fA-F]{64}$')
+
+
+def validate_encryption_key(key_hex: str) -> bool:
+ """Check if key_hex is a valid 64-character hex string."""
+ return bool(_HEX_64_RE.match(key_hex))
+
+
+def encrypt_api_key(api_key: str, encryption_key_hex: str) -> str:
+ """Encrypt an API key using AES-256-GCM with the shared key.
+
+ Args:
+ api_key: The plaintext OpenRouter API key.
+ encryption_key_hex: 64-char hex string (the shared key).
+
+ Returns:
+ Encrypted string in format "enc:base64data".
+
+ Raises:
+ ValueError: If encryption_key_hex is not a valid 64-char hex string.
+ """
+ if not validate_encryption_key(encryption_key_hex):
+ raise ValueError("Encryption key must be exactly 64 hexadecimal characters")
+
+ key = bytes.fromhex(encryption_key_hex)
+ nonce = os.urandom(12)
+ aesgcm = AESGCM(key)
+ ciphertext = aesgcm.encrypt(nonce, api_key.encode("utf-8"), None)
+ encoded = base64.b64encode(nonce + ciphertext).decode("ascii")
+ return f"enc:{encoded}"
diff --git a/bazarr/subtitles/tools/translate/services/gemini_translator.py b/bazarr/subtitles/tools/translate/services/gemini_translator.py
index e1601f8f87..54449faed8 100644
--- a/bazarr/subtitles/tools/translate/services/gemini_translator.py
+++ b/bazarr/subtitles/tools/translate/services/gemini_translator.py
@@ -28,6 +28,10 @@
from ..core.translator_utils import add_translator_info, get_description, create_process_result
logger = logging.getLogger(__name__)
+DEFAULT_GEMINI_BATCH_SIZE = 300
+DEFAULT_GEMINI_KEY_COOLDOWN_SECONDS = 60
+_GEMINI_KEY_COOLDOWNS = {}
+_GEMINI_KEY_COOLDOWNS_LOCK = threading.Lock()
class SubtitleObject(typing.TypedDict):
@@ -58,7 +62,9 @@ def __init__(self, source_srt_file, dest_srt_file, to_lang, media_type, sonarr_s
self.orig_to_lang = orig_to_lang
self.gemini_api_key = None
+ self.api_keys = []
self.current_api_key = None
+ self.current_api_index = -1
self.current_api_number = 1
self.backup_api_number = 2
self.target_language = None
@@ -67,11 +73,9 @@ def __init__(self, source_srt_file, dest_srt_file, to_lang, media_type, sonarr_s
self.start_line = 1
self.description = None
self.model_name = "gemini-2.0-flash"
- self.batch_size = 100
+ self.batch_size = DEFAULT_GEMINI_BATCH_SIZE
self.free_quota = True
self.error_log = False
- self.token_limit = 0
- self.token_count = 0
self.interrupt_flag = False
self.progress_file = None
self.current_progress = 0
@@ -86,17 +90,16 @@ def translate(self, job_id):
logger.debug(f'BAZARR is sending subtitle file to Gemini for translation')
logger.info(f"BAZARR is sending subtitle file to Gemini for translation " + self.source_srt_file)
- self.gemini_api_key = settings.translator.gemini_key
- self.current_api_key = self.gemini_api_key
+ self.api_keys = self._get_configured_api_keys()
+ self.current_api_key = self._select_next_api_key()
+ self.gemini_api_key = self.current_api_key
self.target_language = language_from_alpha3(self.to_lang)
self.input_file = self.source_srt_file
self.output_file = self.dest_srt_file
self.model_name = settings.translator.gemini_model
+ self.batch_size = self._get_batch_size()
self.description = get_description(self.media_type, self.radarr_id, self.sonarr_series_id)
- if "2.5-flash" in self.model_name or "pro" in self.model_name:
- self.batch_size = 300
-
if self.input_file:
self.progress_file = os.path.join(os.path.dirname(self.input_file), f".{os.path.basename(self.input_file)}.progress")
@@ -107,10 +110,11 @@ def translate(self, job_id):
add_translator_info(self.dest_srt_file, f"# Subtitles translated with {settings.translator.gemini_model} # ")
except Exception as e:
jobs_queue.update_job_progress(job_id=job_id, progress_message=f'Gemini translation error: {str(e)}')
+ raise
except Exception as e:
logger.error(f'BAZARR encountered an error translating with Gemini: {str(e)}')
- return False
+ raise
else:
message = (f"{language_from_alpha2(self.from_lang)} subtitles translated to "
@@ -206,53 +210,113 @@ def setup_signal_handlers(self):
return True
return False
- def _get_token_limit(self) -> int:
- """
- Get the token limit for the current model.
+ def _get_batch_size(self) -> int:
+ try:
+ batch_size = int(settings.translator.gemini_batch_size)
+ except (AttributeError, TypeError, ValueError):
+ batch_size = DEFAULT_GEMINI_BATCH_SIZE
+ return max(1, batch_size)
- Returns:
- int: Token limit for the current model according to https://cloud.google.com/vertex-ai/generative-ai/docs/models/gemini/2-5-flash
- """
- if "2.0-flash" in self.model_name:
- return 7000
- elif "2.5-flash" in self.model_name or "pro" in self.model_name:
- return 50000
- else:
- return 7000
+ def _get_configured_api_keys(self) -> List[str]:
+ keys = []
+ seen_keys = set()
+ configured_keys = getattr(settings.translator, "gemini_keys", [])
- def _validate_token_size(self, contents: str) -> bool:
- """
- Validate the token size of the input contents.
+ if not isinstance(configured_keys, list):
+ configured_keys = [configured_keys]
- Args:
- contents (str): Input contents to validate
+ for raw_key in configured_keys:
+ key = str(raw_key).strip()
+ if key and key not in seen_keys:
+ keys.append(key)
+ seen_keys.add(key)
- Returns:
- bool: True if token size is valid, False otherwise
- """
- return True
+ return keys
- current_progress = 0
+ @staticmethod
+ def _is_key_on_cooldown(api_key: str) -> bool:
+ now = time.time()
+ with _GEMINI_KEY_COOLDOWNS_LOCK:
+ expires_at = _GEMINI_KEY_COOLDOWNS.get(api_key)
+ if expires_at is None:
+ return False
+ if expires_at <= now:
+ _GEMINI_KEY_COOLDOWNS.pop(api_key, None)
+ return False
+ return True
- def _process_batch(
- self,
- batch: List[SubtitleObject], # Changed from list[SubtitleObject]
- translated_subtitle: List[Subtitle], # Changed from list[Subtitle]
- total: int,
- retry_num=3
- ):
- """
- Process a batch of subtitles for translation with accurate progress tracking.
+ def _select_next_api_key(self):
+ if not self.api_keys:
+ self.current_api_key = None
+ return None
- Args:
- batch (List[SubtitleObject]): Batch of subtitles to translate
- translated_subtitle (List[Subtitle]): List to store translated subtitles
- total (int): Total number of subtitles to translate
- """
+ total_keys = len(self.api_keys)
+ start_index = self.current_api_index if self.current_api_index >= 0 else -1
- url = f"https://generativelanguage.googleapis.com/v1beta/models/{self.model_name}:generateContent?key={self.current_api_key}"
+ for offset in range(1, total_keys + 1):
+ candidate_index = (start_index + offset) % total_keys
+ candidate_key = self.api_keys[candidate_index]
+ if not self._is_key_on_cooldown(candidate_key):
+ self.current_api_index = candidate_index
+ self.current_api_key = candidate_key
+ return candidate_key
+
+ self.current_api_key = None
+ return None
+
+ @staticmethod
+ def _is_rate_limited_response(response) -> bool:
+ if response is None:
+ return False
+
+ if response.status_code == 429:
+ return True
+
+ try:
+ error_body = response.json().get("error", {})
+ except Exception:
+ return False
+
+ error_status = str(error_body.get("status", "")).upper()
+ error_message = str(error_body.get("message", "")).lower()
+ return error_status == "RESOURCE_EXHAUSTED" or "rate limit" in error_message or "quota" in error_message
+
+ @staticmethod
+ def _get_retry_after_seconds(response) -> int:
+ if response is not None:
+ retry_after = response.headers.get("Retry-After")
+ if retry_after:
+ try:
+ return max(1, int(float(retry_after)))
+ except (TypeError, ValueError):
+ pass
- payload = json.dumps({
+ return DEFAULT_GEMINI_KEY_COOLDOWN_SECONDS
+
+ @staticmethod
+ def _set_key_cooldown(api_key: str, cooldown_seconds: int):
+ with _GEMINI_KEY_COOLDOWNS_LOCK:
+ _GEMINI_KEY_COOLDOWNS[api_key] = time.time() + max(1, cooldown_seconds)
+
+ def _handle_rate_limited_key(self, response):
+ if not self.current_api_key:
+ raise RuntimeError("All Gemini API keys are currently rate limited. Please wait before retrying.")
+
+ cooldown_seconds = self._get_retry_after_seconds(response)
+ self._set_key_cooldown(self.current_api_key, cooldown_seconds)
+
+ rotated_key = self._select_next_api_key()
+ if not rotated_key:
+ raise RuntimeError("All Gemini API keys are currently rate limited. Please wait before retrying.")
+
+ if self.job_id is not None:
+ jobs_queue.update_job_progress(
+ job_id=self.job_id,
+ progress_message="Gemini key rate limited. Rotating to another configured key.",
+ )
+
+ def _build_generate_payload(self, batch: List[SubtitleObject]) -> dict:
+ return {
"system_instruction": {
"parts": [
{
@@ -270,7 +334,29 @@ def _process_batch(
]
}
]
- })
+ }
+
+ current_progress = 0
+
+ def _process_batch(
+ self,
+ batch: List[SubtitleObject], # Changed from list[SubtitleObject]
+ translated_subtitle: List[Subtitle], # Changed from list[Subtitle]
+ total: int,
+ retry_num=3
+ ):
+ """
+ Process a batch of subtitles for translation with accurate progress tracking.
+
+ Args:
+ batch (List[SubtitleObject]): Batch of subtitles to translate
+ translated_subtitle (List[Subtitle]): List to store translated subtitles
+ total (int): Total number of subtitles to translate
+ """
+
+ url = f"https://generativelanguage.googleapis.com/v1beta/models/{self.model_name}:generateContent?key={self.current_api_key}"
+
+ payload = json.dumps(self._build_generate_payload(batch), ensure_ascii=False)
headers = {
'Content-Type': 'application/json'
}
@@ -313,6 +399,12 @@ def clean_json_string(json_string):
return self.current_progress
except Exception as e:
+ response = getattr(e, "response", None)
+ if self._is_rate_limited_response(response):
+ self._handle_rate_limited_key(response)
+ if retry_num > 0:
+ return self._process_batch(batch, translated_subtitle, total, retry_num - 1)
+
if retry_num > 0:
return self._process_batch(batch, translated_subtitle, total, retry_num - 1)
else:
@@ -360,8 +452,18 @@ def _dominant_strong_direction(s: str) -> str:
translated_subtitle[int(line["index"])].content = line["content"]
def _translate_with_gemini(self):
+ if not self.api_keys:
+ jobs_queue.update_job_progress(
+ job_id=self.job_id,
+ progress_message="Please provide at least one valid Gemini API key.",
+ )
+ return
+
if not self.current_api_key:
- jobs_queue.update_job_progress(job_id=self.job_id, progress_message="Please provide a valid Gemini API key.")
+ jobs_queue.update_job_progress(
+ job_id=self.job_id,
+ progress_message="All Gemini API keys are currently rate limited. Please wait before retrying.",
+ )
return
if not self.target_language:
@@ -372,8 +474,6 @@ def _translate_with_gemini(self):
jobs_queue.update_job_progress(job_id=self.job_id, progress_message="Please provide a subtitle file.")
return
- self.token_limit = self._get_token_limit()
-
try:
with open(self.input_file, "r", encoding="utf-8") as original_file:
original_text = original_file.read()
@@ -395,7 +495,6 @@ def _translate_with_gemini(self):
i = self.start_line - 1
total = len(original_subtitle)
batch = [SubtitleObject(index=str(i), content=original_subtitle[i].content)]
-
i += 1
# Save initial progress
@@ -411,35 +510,6 @@ def _translate_with_gemini(self):
continue
try:
- if not self._validate_token_size(json.dumps(batch, ensure_ascii=False)):
- jobs_queue.update_job_progress(
- job_id=self.job_id,
- progress_message=f"Token size ({int(self.token_count / 0.9)}) exceeds limit ("
- f"{self.token_limit}) for {self.model_name}."
- )
- user_prompt = "0"
- while not user_prompt.isdigit() or int(user_prompt) <= 0:
- user_prompt = jobs_queue.update_job_progress(
- job_id=self.job_id,
- progress_message=f"Please enter a new batch size (current: {self.batch_size}): "
- )
- if user_prompt.isdigit() and int(user_prompt) > 0:
- new_batch_size = int(user_prompt)
- decrement = self.batch_size - new_batch_size
- if decrement > 0:
- for _ in range(decrement):
- i -= 1
- batch.pop()
- self.batch_size = new_batch_size
- jobs_queue.update_job_progress(job_id=self.job_id,
- progress_message=f"Batch size updated to "
- f"{self.batch_size}.")
- else:
- jobs_queue.update_job_progress(job_id=self.job_id,
- progress_message="Invalid input. Batch size must"
- " be a positive integer.")
- continue
-
start_time = time.time()
self._process_batch(batch, translated_subtitle, total)
end_time = time.time()
@@ -456,7 +526,8 @@ def _translate_with_gemini(self):
raise e
# Check if we exited the loop due to an interrupt
- jobs_queue.update_job_progress(job_id=self.job_id, progress_value=total)
+ jobs_queue.update_job_progress(job_id=self.job_id, progress_value=total,
+ progress_message=self.source_srt_file)
if self.interrupt_flag:
# File will be automatically closed by the with statement
self._clear_progress()
@@ -472,4 +543,10 @@ def _translate_with_gemini(self):
jobs_queue.update_job_progress(job_id=self.job_id, progress_value=total,
progress_message=f'Gemini translation failed: {str(e)}')
self._clear_progress()
+ if self.output_file and os.path.exists(self.output_file):
+ try:
+ if os.path.getsize(self.output_file) == 0:
+ os.remove(self.output_file)
+ except OSError:
+ pass
raise e
diff --git a/bazarr/subtitles/tools/translate/services/google_translator.py b/bazarr/subtitles/tools/translate/services/google_translator.py
index eea90dfa01..f121f88caf 100644
--- a/bazarr/subtitles/tools/translate/services/google_translator.py
+++ b/bazarr/subtitles/tools/translate/services/google_translator.py
@@ -95,7 +95,7 @@ def translate_line(line_id, subtitle_line):
jobs_queue.update_job_progress(job_id=job_id,
progress_message=f'Translation failed: Unable to translate '
f'malformed subtitles for {self.source_srt_file}')
- return False
+ raise
try:
subs.save(self.dest_srt_file)
@@ -121,7 +121,7 @@ def translate_line(line_id, subtitle_line):
logger.error(f'BAZARR encountered an error during translation: {str(e)}')
jobs_queue.update_job_progress(job_id=job_id,
progress_message=f'Google translation failed: {str(e)}')
- return False
+ raise
@retry(exceptions=(TooManyRequests, RequestError), tries=6, delay=1, backoff=2, jitter=(0, 1))
def _translate_text(self, text, job_id):
diff --git a/bazarr/subtitles/tools/translate/services/lingarr_translator.py b/bazarr/subtitles/tools/translate/services/lingarr_translator.py
index 646b6e402c..276ee124f6 100644
--- a/bazarr/subtitles/tools/translate/services/lingarr_translator.py
+++ b/bazarr/subtitles/tools/translate/services/lingarr_translator.py
@@ -64,7 +64,7 @@ def translate(self, job_id=None):
logger.error(f'Translation failed for {self.source_srt_file}')
jobs_queue.update_job_progress(job_id=job_id,
progress_message=f'Translation failed for {self.source_srt_file}')
- return False
+ raise RuntimeError(f'Translation failed for {self.source_srt_file}')
logger.debug(f'BAZARR saving Lingarr translated subtitles to {self.dest_srt_file}')
translation_map = {}
@@ -108,7 +108,7 @@ def translate(self, job_id=None):
except Exception as e:
logger.error(f'BAZARR encountered an error during Lingarr translation: {str(e)}')
jobs_queue.update_job_progress(job_id=job_id, progress_message=f'Lingarr translation failed: {str(e)}')
- return False
+ raise
@retry(exceptions=(TooManyRequests, RequestError, requests.exceptions.RequestException), tries=3, delay=1,
backoff=2, jitter=(0, 1))
diff --git a/bazarr/subtitles/tools/translate/services/openrouter_translator.py b/bazarr/subtitles/tools/translate/services/openrouter_translator.py
index 7d897bfcf3..82b107ea31 100644
--- a/bazarr/subtitles/tools/translate/services/openrouter_translator.py
+++ b/bazarr/subtitles/tools/translate/services/openrouter_translator.py
@@ -14,8 +14,10 @@
from radarr.history import history_log_movie
from sonarr.history import history_log
from app.event_handler import show_progress, hide_progress, show_message
+from app.jobs_queue import jobs_queue
from ..core.translator_utils import add_translator_info, create_process_result, get_title
+from .auth import get_translator_auth_headers
logger = logging.getLogger(__name__)
@@ -51,27 +53,30 @@ def __init__(self, source_srt_file, dest_srt_file, lang_obj, to_lang, from_lang,
def _build_reasoning_config(self):
"""
Build reasoning configuration based on Bazarr settings.
- Maps effort levels to max_tokens for the AI Subtitle Translator service.
+ Sends effort level directly to the AI Subtitle Translator service.
"""
reasoning_mode = getattr(settings.translator, 'openrouter_reasoning', 'disabled')
-
+
if reasoning_mode == 'disabled':
return None
-
- # Map effort levels to max_tokens for reasoning
- effort_to_tokens = {
- 'low': 1000, # Minimal thinking
- 'medium': 2000, # Default balanced
- 'high': 4000, # Extended thinking
- }
-
- max_tokens = effort_to_tokens.get(reasoning_mode, 2000)
-
+
return {
- 'enabled': True,
- 'maxTokens': max_tokens,
+ 'effort': reasoning_mode,
}
+ def _get_api_key_value(self):
+ """Get the API key, encrypted if an encryption key is configured."""
+ api_key = settings.translator.openrouter_api_key
+ encryption_key = settings.translator.openrouter_encryption_key
+ if encryption_key:
+ try:
+ from .encryption import encrypt_api_key
+ api_key = encrypt_api_key(api_key, encryption_key)
+ except ValueError as e:
+ logger.error(f'Invalid encryption key: {e}')
+ raise ValueError("Invalid encryption key format. Check your encryption key in Settings.")
+ return api_key
+
def translate(self, job_id=None):
try:
subs = pysubs2.load(self.source_srt_file, encoding='utf-8')
@@ -85,7 +90,7 @@ def translate(self, job_id=None):
logger.debug(f'Starting AI translation for {self.source_srt_file}')
# Submit job and poll for completion
- translated_lines = self._submit_and_poll(lines_list)
+ translated_lines = self._submit_and_poll(lines_list, bazarr_job_id=job_id)
if translated_lines is None:
logger.error(f'Translation failed for {self.source_srt_file}')
@@ -132,7 +137,7 @@ def translate(self, job_id=None):
hide_progress(id=f'translate_progress_{self.dest_srt_file}')
return False
- def _submit_and_poll(self, lines_list: List[str]) -> Optional[List[Dict[str, Any]]]:
+ def _submit_and_poll(self, lines_list: List[str], bazarr_job_id=None) -> Optional[List[Dict[str, Any]]]:
"""Submit translation job and poll for completion with progress updates"""
try:
# Prepare language codes
@@ -146,6 +151,10 @@ def _submit_and_poll(self, lines_list: List[str]) -> Optional[List[Dict[str, Any
source_lang = self.language_code_convert_dict.get(source_lang, source_lang)
target_lang = self.language_code_convert_dict.get(target_lang, target_lang)
+ # Resolve alpha2 codes to full language names for the AI translator prompt
+ source_lang = language_from_alpha2(source_lang) or source_lang
+ target_lang = language_from_alpha2(target_lang) or target_lang
+
logger.debug(f'BAZARR translation language codes: from_lang={self.from_lang}, to_lang={self.to_lang}, '
f'orig_to_lang={self.orig_to_lang}, final source={source_lang}, final target={target_lang}')
@@ -174,10 +183,11 @@ def _submit_and_poll(self, lines_list: List[str]) -> Optional[List[Dict[str, Any
"lines": lines_payload,
# Add configuration from Bazarr settings
"config": {
- "apiKey": settings.translator.openrouter_api_key,
+ "apiKey": self._get_api_key_value(),
"model": settings.translator.openrouter_model,
"temperature": settings.translator.openrouter_temperature,
"maxConcurrentJobs": settings.translator.openrouter_max_concurrent,
+ "parallelBatches": settings.translator.openrouter_parallel_batches,
"reasoning": self._build_reasoning_config(),
}
}
@@ -189,7 +199,7 @@ def _submit_and_poll(self, lines_list: List[str]) -> Optional[List[Dict[str, Any
submit_response = requests.post(
f"{base_url}/api/v1/jobs/translate/content",
json=payload,
- headers={"Content-Type": "application/json"},
+ headers={"Content-Type": "application/json", **get_translator_auth_headers()},
timeout=30
)
@@ -207,7 +217,7 @@ def _submit_and_poll(self, lines_list: List[str]) -> Optional[List[Dict[str, Any
logger.debug(f'BAZARR translation job submitted: {job_id}')
# Poll for completion
- return self._poll_job(base_url, job_id, len(lines_payload))
+ return self._poll_job(base_url, job_id, len(lines_payload), bazarr_job_id=bazarr_job_id)
except requests.exceptions.Timeout:
logger.error('AI Subtitle Translator request timed out')
@@ -219,7 +229,7 @@ def _submit_and_poll(self, lines_list: List[str]) -> Optional[List[Dict[str, Any
logger.error(f'AI Subtitle Translator error: {str(e)}')
return None
- def _poll_job(self, base_url: str, job_id: str, total_lines: int) -> Optional[Any]:
+ def _poll_job(self, base_url: str, job_id: str, total_lines: int, bazarr_job_id=None) -> Optional[Any]:
"""Poll job status until completion"""
poll_interval = 2 # seconds
max_wait_time = 1800 # 30 minutes
@@ -229,6 +239,7 @@ def _poll_job(self, base_url: str, job_id: str, total_lines: int) -> Optional[An
try:
status_response = requests.get(
f"{base_url}/api/v1/jobs/{job_id}",
+ headers=get_translator_auth_headers(),
timeout=10
)
@@ -252,6 +263,16 @@ def _poll_job(self, base_url: str, job_id: str, total_lines: int) -> Optional[An
count=100
)
+ # Sync progress to bazarr jobs queue (for NotificationDrawer)
+ if bazarr_job_id:
+ model_used = job_status.get("model_used", settings.translator.openrouter_model or "")
+ jobs_queue.update_job_progress(
+ job_id=bazarr_job_id,
+ progress_value=progress,
+ progress_max=100,
+ progress_message=f'{message} [{model_used}]' if model_used else message
+ )
+
if status == "completed":
hide_progress(id=f'translate_progress_{self.dest_srt_file}')
result = job_status.get("result")
@@ -273,6 +294,13 @@ def _poll_job(self, base_url: str, job_id: str, total_lines: int) -> Optional[An
show_message(f"Translation failed: {error}")
return None
+ elif status == "partial":
+ hide_progress(id=f'translate_progress_{self.dest_srt_file}')
+ error = job_status.get("error", message or "Partial translation")
+ logger.error(f"Translation partially failed: {error}")
+ show_message(f"Translation failed (partial): {error}")
+ return None
+
elif status == "cancelled":
hide_progress(id=f'translate_progress_{self.dest_srt_file}')
logger.info("Translation job was cancelled")
@@ -301,7 +329,7 @@ def _translate_sync(self, lines_list: List[str], payload: Dict[str, Any]) -> Opt
response = requests.post(
f"{base_url}/api/v1/translate/content",
json=payload,
- headers={"Content-Type": "application/json"},
+ headers={"Content-Type": "application/json", **get_translator_auth_headers()},
timeout=1800
)
diff --git a/bazarr/subtitles/upgrade.py b/bazarr/subtitles/upgrade.py
index 88a095d748..7613fecfd8 100644
--- a/bazarr/subtitles/upgrade.py
+++ b/bazarr/subtitles/upgrade.py
@@ -24,25 +24,50 @@
from app.event_handler import event_stream
-def upgrade_subtitles():
+def upgrade_subtitles(wait_for_completion=False):
use_sonarr = settings.general.use_sonarr
use_radarr = settings.general.use_radarr
if use_sonarr:
- upgrade_episodes_subtitles()
+ upgrade_episodes_subtitles(wait_for_completion=wait_for_completion)
if use_radarr:
- upgrade_movies_subtitles()
+ upgrade_movies_subtitles(wait_for_completion=wait_for_completion)
logging.info('BAZARR Finished searching for Subtitles to upgrade. Check History for more information.')
-def upgrade_episodes_subtitles(job_id=None):
+def upgrade_episodes_subtitles(job_id=None, sonarr_series_ids=None, wait_for_completion=False):
if not job_id:
- jobs_queue.add_job_from_function("Trying to upgrade episodes subtitles", is_progress=True)
+ jobs_queue.add_job_from_function("Trying to upgrade episodes subtitles", is_progress=True,
+ wait_for_completion=wait_for_completion)
return
episodes_to_upgrade = get_upgradable_episode_subtitles()
+
+ query = select(TableHistory.id,
+ TableShows.title.label('seriesTitle'),
+ TableEpisodes.season,
+ TableEpisodes.episode,
+ TableEpisodes.title,
+ TableHistory.language,
+ TableEpisodes.audio_language,
+ TableHistory.video_path,
+ TableEpisodes.sceneName,
+ TableHistory.score,
+ TableHistory.sonarrEpisodeId,
+ TableHistory.sonarrSeriesId,
+ TableHistory.subtitles_path,
+ TableEpisodes.path,
+ TableShows.profileId,
+ TableEpisodes.subtitles.label('external_subtitles')) \
+ .select_from(TableHistory) \
+ .join(TableShows, onclause=TableHistory.sonarrSeriesId == TableShows.sonarrSeriesId) \
+ .join(TableEpisodes, onclause=TableHistory.sonarrEpisodeId == TableEpisodes.sonarrEpisodeId)
+
+ if sonarr_series_ids:
+ query = query.where(TableHistory.sonarrSeriesId.in_(sonarr_series_ids))
+
episodes_data = [{
'id': x.id,
'seriesTitle': x.seriesTitle,
@@ -60,26 +85,7 @@ def upgrade_episodes_subtitles(job_id=None):
'path': x.path,
'profileId': x.profileId,
'external_subtitles': [y[1] for y in ast.literal_eval(x.external_subtitles) if y[1]],
- } for x in database.execute(
- select(TableHistory.id,
- TableShows.title.label('seriesTitle'),
- TableEpisodes.season,
- TableEpisodes.episode,
- TableEpisodes.title,
- TableHistory.language,
- TableEpisodes.audio_language,
- TableHistory.video_path,
- TableEpisodes.sceneName,
- TableHistory.score,
- TableHistory.sonarrEpisodeId,
- TableHistory.sonarrSeriesId,
- TableHistory.subtitles_path,
- TableEpisodes.path,
- TableShows.profileId,
- TableEpisodes.subtitles.label('external_subtitles'))
- .select_from(TableHistory)
- .join(TableShows, onclause=TableHistory.sonarrSeriesId == TableShows.sonarrSeriesId)
- .join(TableEpisodes, onclause=TableHistory.sonarrEpisodeId == TableEpisodes.sonarrEpisodeId))
+ } for x in database.execute(query)
.all() if _language_still_desired(x.language, x.profileId) and
x.video_path == x.path
]
@@ -87,7 +93,14 @@ def upgrade_episodes_subtitles(job_id=None):
for item in episodes_data:
# do not consider subtitles that do not exist on disk anymore
if item['subtitles_path'] not in item['external_subtitles']:
- continue
+ current_sub = _find_current_subtitle_for_language(item['language'], item['external_subtitles'])
+ if current_sub:
+ logging.debug(f"Upgrade candidate {item['id']} ({item['seriesTitle']} S{item['season']:02d}E"
+ f"{item['episode']:02d}): history path no longer on disk, using current subtitle "
+ f"for same language ({current_sub})")
+ item['subtitles_path'] = current_sub
+ else:
+ continue
# Mark upgradable and get original_id
item.update({'original_id': episodes_to_upgrade.get(item['id'])})
@@ -134,7 +147,7 @@ def upgrade_episodes_subtitles(job_id=None):
episode['seriesTitle'],
'series',
episode['profileId'],
- forced_minimum_score=int(episode['score']) + 1,
+ forced_minimum_score=int(episode['score'] or 0) + 1,
is_upgrade=True,
previous_subtitles_to_delete=path_mappings.path_replace(
episode['subtitles_path']),
@@ -153,27 +166,15 @@ def upgrade_episodes_subtitles(job_id=None):
jobs_queue.update_job_name(job_id=job_id, new_job_name='Tried to upgrade episodes subtitles')
-def upgrade_movies_subtitles(job_id=None):
+def upgrade_movies_subtitles(job_id=None, radarr_ids=None, wait_for_completion=False):
if not job_id:
- jobs_queue.add_job_from_function("Trying to upgrade movies subtitles", is_progress=True)
+ jobs_queue.add_job_from_function("Trying to upgrade movies subtitles", is_progress=True,
+ wait_for_completion=wait_for_completion)
return
movies_to_upgrade = get_upgradable_movies_subtitles()
- movies_data = [{
- 'id': x.id,
- 'title': x.title,
- 'language': x.language,
- 'audio_language': x.audio_language,
- 'video_path': x.video_path,
- 'sceneName': x.sceneName,
- 'score': x.score,
- 'radarrId': x.radarrId,
- 'path': x.path,
- 'profileId': x.profileId,
- 'subtitles_path': x.subtitles_path,
- 'external_subtitles': [y[1] for y in ast.literal_eval(x.external_subtitles) if y[1]],
- } for x in database.execute(
- select(TableHistoryMovie.id,
+
+ query = select(TableHistoryMovie.id,
TableMovies.title,
TableHistoryMovie.language,
TableMovies.audio_language,
@@ -184,17 +185,55 @@ def upgrade_movies_subtitles(job_id=None):
TableHistoryMovie.subtitles_path,
TableMovies.path,
TableMovies.profileId,
- TableMovies.subtitles.label('external_subtitles'))
- .select_from(TableHistoryMovie)
- .join(TableMovies, onclause=TableHistoryMovie.radarrId == TableMovies.radarrId))
- .all() if _language_still_desired(x.language, x.profileId) and
- x.video_path == x.path
- ]
+ TableMovies.subtitles.label('external_subtitles')) \
+ .select_from(TableHistoryMovie) \
+ .join(TableMovies, onclause=TableHistoryMovie.radarrId == TableMovies.radarrId)
+
+ if radarr_ids:
+ query = query.where(TableHistoryMovie.radarrId.in_(radarr_ids))
+
+ all_rows = database.execute(query).all()
+ movies_data = []
+ for x in all_rows:
+ if not _language_still_desired(x.language, x.profileId):
+ if x.id in movies_to_upgrade:
+ logging.debug(f"Upgrade candidate {x.id} ({x.title}) dropped: language {x.language} no longer desired "
+ f"in profile {x.profileId}")
+ continue
+ if x.video_path != x.path:
+ if x.id in movies_to_upgrade:
+ logging.debug(f"Upgrade candidate {x.id} ({x.title}) dropped: video_path mismatch "
+ f"(history={x.video_path} != current={x.path})")
+ continue
+ movies_data.append({
+ 'id': x.id,
+ 'title': x.title,
+ 'language': x.language,
+ 'audio_language': x.audio_language,
+ 'video_path': x.video_path,
+ 'sceneName': x.sceneName,
+ 'score': x.score,
+ 'radarrId': x.radarrId,
+ 'path': x.path,
+ 'profileId': x.profileId,
+ 'subtitles_path': x.subtitles_path,
+ 'external_subtitles': [y[1] for y in ast.literal_eval(x.external_subtitles) if y[1]],
+ })
for item in movies_data:
# do not consider subtitles that do not exist on disk anymore
if item['subtitles_path'] not in item['external_subtitles']:
- continue
+ # try to find a current subtitle for the same language (file may have been renamed/re-downloaded)
+ current_sub = _find_current_subtitle_for_language(item['language'], item['external_subtitles'])
+ if current_sub:
+ logging.debug(f"Upgrade candidate {item['id']} ({item['title']}): history path no longer on disk, "
+ f"using current subtitle for same language ({current_sub})")
+ item['subtitles_path'] = current_sub
+ else:
+ if item['id'] in movies_to_upgrade:
+ logging.debug(f"Upgrade candidate {item['id']} ({item['title']}) dropped: no subtitle for language "
+ f"{item['language']} found on disk")
+ continue
# Mark upgradable and get original_id
item.update({'original_id': movies_to_upgrade.get(item['id'])})
@@ -239,7 +278,7 @@ def upgrade_movies_subtitles(job_id=None):
movie['title'],
'movie',
movie['profileId'],
- forced_minimum_score=int(movie['score']) + 1,
+ forced_minimum_score=int(movie['score'] or 0) + 1,
is_upgrade=True,
previous_subtitles_to_delete=path_mappings.path_replace_movie(
movie['subtitles_path']),
@@ -269,6 +308,33 @@ def get_queries_condition_parameters():
return [minimum_timestamp, query_actions]
+def _find_current_subtitle_for_language(language_string, external_subtitles):
+ """Find a current subtitle file on disk that matches the language from history.
+
+ When a subtitle was re-downloaded or renamed (e.g. .hu.hi.srt -> .hu.srt),
+ the history still references the old path. This finds the current file for
+ the same language so upgrades can still proceed.
+ """
+ lang_code = language_string.split(':')[0]
+ is_hi = language_string.endswith(':hi')
+ is_forced = language_string.endswith(':forced')
+
+ for sub_path in external_subtitles:
+ sub_lower = sub_path.lower()
+ # Check if language code is in the filename
+ if f'.{lang_code.lower()}.' not in sub_lower and not sub_lower.endswith(f'.{lang_code.lower()}'):
+ continue
+ # Match HI/forced flags
+ has_hi = '.hi.' in sub_lower or sub_lower.endswith('.hi.srt')
+ has_forced = '.forced.' in sub_lower or sub_lower.endswith('.forced.srt')
+ if is_hi == has_hi and is_forced == has_forced:
+ return sub_path
+ # If we wanted HI but only non-HI exists (or vice versa), still a candidate
+ if not is_forced and not has_forced and lang_code.lower() in sub_lower:
+ return sub_path
+ return None
+
+
def parse_language_string(language_string):
if language_string.endswith('forced'):
language = language_string.split(':')[0]
@@ -307,7 +373,7 @@ def get_upgradable_episode_subtitles(history_id_list=None):
upgradable_episodes_conditions = [(TableHistory.action.in_(query_actions)),
(TableHistory.timestamp > minimum_timestamp),
or_(and_(TableHistory.score.is_(None), TableHistory.action == 6),
- (TableHistory.score < 357))]
+ (TableHistory.score < TableHistory.score_out_of - 3))]
upgradable_episodes_conditions += get_exclusion_clause('series')
subtitles_to_upgrade = database.execute(
select(TableHistory.id,
@@ -374,7 +440,7 @@ def get_upgradable_movies_subtitles(history_id_list=None):
upgradable_movies_conditions = [(TableHistoryMovie.action.in_(query_actions)),
(TableHistoryMovie.timestamp > minimum_timestamp),
or_(and_(TableHistoryMovie.score.is_(None), TableHistoryMovie.action == 6),
- (TableHistoryMovie.score < 117))]
+ (TableHistoryMovie.score < TableHistoryMovie.score_out_of - 3))]
upgradable_movies_conditions += get_exclusion_clause('movie')
subtitles_to_upgrade = database.execute(
select(TableHistoryMovie.id,
diff --git a/bazarr/subtitles/upload.py b/bazarr/subtitles/upload.py
index 433bfe4e60..aa2f71ced5 100644
--- a/bazarr/subtitles/upload.py
+++ b/bazarr/subtitles/upload.py
@@ -8,6 +8,7 @@
from subzero.language import Language
from subliminal_patch.core import save_subtitles
from subliminal_patch.subtitle import Subtitle
+from subliminal_patch.score import MAX_SCORES
from pysubs2.formats import get_format_identifier
from languages.get_languages import language_from_alpha3, alpha2_from_alpha3, alpha3_from_alpha2
@@ -197,14 +198,13 @@ def manual_upload_subtitle(path, language, forced, hi, media_type, subtitle, fil
result = result[0]
provider = "manual"
if media_type == 'series':
- score = 360
- history_log(4, sonarrSeriesId, sonarrEpisodeId, result, fake_provider=provider, fake_score=score)
+ history_log(4, sonarrSeriesId, sonarrEpisodeId, result, fake_provider=provider,
+ fake_score=MAX_SCORES['episode'])
if not settings.general.dont_notify_manual_actions:
send_notifications(sonarrSeriesId, sonarrEpisodeId, result.message)
store_subtitles(result.path, path)
else:
- score = 120
- history_log_movie(4, radarrId, result, fake_provider=provider, fake_score=score)
+ history_log_movie(4, radarrId, result, fake_provider=provider, fake_score=MAX_SCORES['movie'])
if not settings.general.dont_notify_manual_actions:
send_notifications_movie(radarrId, result.message)
store_subtitles_movie(result.path, path)
diff --git a/bazarr/subtitles/utils.py b/bazarr/subtitles/utils.py
index 585c264db9..b2da03e09e 100644
--- a/bazarr/subtitles/utils.py
+++ b/bazarr/subtitles/utils.py
@@ -8,11 +8,11 @@
from subzero.language import Language
from subzero.video import parse_video
from guessit.jsonutils import GuessitEncoder
+from subliminal_patch.score import MAX_SCORES, DEFAULT_SCORES
from app.config import settings
from languages.custom_lang import CustomLanguage
from app.database import get_profiles_list
-from subtitles.tools.score import movie_score, series_score
from .refiners import registered as registered_refiners
@@ -72,11 +72,19 @@ def _get_lang_obj(alpha3):
def _get_scores(media_type, min_movie=None, min_ep=None):
series = "series" == media_type
- handler = series_score if series else movie_score
- min_movie = min_movie or (60 * 100 / handler.max_score)
- min_ep = min_ep or (240 * 100 / handler.max_score)
- min_score_ = int(min_ep if series else min_movie)
- return handler.get_scores(min_score_)
+ handler = DEFAULT_SCORES['episode'] if series else DEFAULT_SCORES['movie']
+
+ max_score = MAX_SCORES['episode' if series else 'movie']
+
+ min_movie = min_movie or (max_score / 2)
+ min_ep = min_ep or (2/3 * max_score)
+ min_score = int(min_ep if series else min_movie)
+
+ return (
+ max_score * min_score / 100,
+ max_score,
+ handler.keys(),
+ )
def get_ban_list(profile_id):
diff --git a/bazarr/subtitles/wanted/movies.py b/bazarr/subtitles/wanted/movies.py
index b00e87aa55..b41e538be4 100644
--- a/bazarr/subtitles/wanted/movies.py
+++ b/bazarr/subtitles/wanted/movies.py
@@ -97,9 +97,10 @@ def wanted_download_subtitles_movie(radarr_id, job_id=None):
logging.info("BAZARR All providers are throttled")
-def wanted_search_missing_subtitles_movies(job_id=None):
+def wanted_search_missing_subtitles_movies(job_id=None, wait_for_completion=False):
if not job_id:
- jobs_queue.add_job_from_function("Searching for missing movies subtitles", is_progress=True)
+ jobs_queue.add_job_from_function("Searching for missing movies subtitles", is_progress=True,
+ wait_for_completion=wait_for_completion)
return
conditions = [(TableMovies.missing_subtitles.is_not(None)),
@@ -119,6 +120,7 @@ def wanted_search_missing_subtitles_movies(job_id=None):
if count_movies == 0:
jobs_queue.update_job_progress(job_id=job_id, progress_value='max')
+ throttled = False
for i, movie in enumerate(movies, start=1):
jobs_queue.update_job_progress(job_id=job_id, progress_value=i, progress_message=movie.title)
@@ -130,7 +132,11 @@ def wanted_search_missing_subtitles_movies(job_id=None):
jobs_queue.update_job_progress(job_id=job_id, progress_value=i, progress_max=count_movies)
else:
logging.info("BAZARR All providers are throttled")
+ throttled = True
break
+ outcome_msg = ("All providers throttled" if throttled
+ else "Search completed")
+ jobs_queue.update_job_progress(job_id=job_id, progress_message=outcome_msg)
jobs_queue.update_job_name(job_id=job_id, new_job_name="Searched for missing movies subtitles")
logging.info('BAZARR Finished searching for missing Movies Subtitles. Check History for more information.')
diff --git a/bazarr/subtitles/wanted/series.py b/bazarr/subtitles/wanted/series.py
index d6deafdad8..359678cf1e 100644
--- a/bazarr/subtitles/wanted/series.py
+++ b/bazarr/subtitles/wanted/series.py
@@ -4,6 +4,7 @@
import ast
import logging
import operator
+import gc
from functools import reduce
@@ -102,9 +103,10 @@ def wanted_download_subtitles(sonarr_episode_id, job_id=None):
logging.info("BAZARR All providers are throttled")
-def wanted_search_missing_subtitles_series(job_id=None):
+def wanted_search_missing_subtitles_series(job_id=None, wait_for_completion=False):
if not job_id:
- jobs_queue.add_job_from_function("Searching for missing series subtitles", is_progress=True)
+ jobs_queue.add_job_from_function("Searching for missing series subtitles", is_progress=True,
+ wait_for_completion=wait_for_completion)
return
conditions = [(TableEpisodes.missing_subtitles.is_not(None)),
@@ -131,6 +133,7 @@ def wanted_search_missing_subtitles_series(job_id=None):
if count_episodes == 0:
jobs_queue.update_job_progress(job_id=job_id, progress_value='max')
+ throttled = False
for i, episode in enumerate(episodes, start=1):
jobs_queue.update_job_progress(job_id=job_id, progress_value=i,
progress_message=f'{episode.title} - S{episode.season:02d}E{episode.episode:02d}'
@@ -144,7 +147,13 @@ def wanted_search_missing_subtitles_series(job_id=None):
jobs_queue.update_job_progress(job_id=job_id, progress_value=i, progress_max=count_episodes)
else:
logging.info("BAZARR All providers are throttled")
+ throttled = True
break
+ outcome_msg = ("All providers throttled" if throttled
+ else "Search completed")
+ jobs_queue.update_job_progress(job_id=job_id, progress_message=outcome_msg)
jobs_queue.update_job_name(job_id=job_id, new_job_name="Searched for missing series subtitles")
logging.info('BAZARR Finished searching for missing Series Subtitles. Check History for more information.')
+
+ gc.collect()
diff --git a/bazarr/utilities/backup.py b/bazarr/utilities/backup.py
index 75368ebfbd..0ce9977286 100644
--- a/bazarr/utilities/backup.py
+++ b/bazarr/utilities/backup.py
@@ -47,9 +47,10 @@ def get_backup_files(fullpath=True):
} for x in file_list]
-def backup_to_zip(job_id=None):
+def backup_to_zip(job_id=None, wait_for_completion=False):
if not job_id:
- jobs_queue.add_job_from_function("Backing up Database and Configuration File", is_progress=False)
+ jobs_queue.add_job_from_function("Backing up Database and Configuration File", is_progress=False,
+ wait_for_completion=wait_for_completion)
return
now = datetime.now()
diff --git a/bazarr/utilities/binaries.json b/bazarr/utilities/binaries.json
index 4255cd005b..6f072d410e 100644
--- a/bazarr/utilities/binaries.json
+++ b/bazarr/utilities/binaries.json
@@ -5,7 +5,7 @@
"directory": "unrar",
"name": "unrar",
"checksum": "be7a08a75ffb1dcc5f8c6b16a75e822c",
- "url": "https://github.com/morpheus65535/bazarr-binaries/raw/master/bin/Linux/aarch64/unrar/unrar"
+ "url": "https://github.com/LavX/bazarr-binaries/raw/master/bin/Linux/aarch64/unrar/unrar"
},
{
"system": "Linux",
@@ -13,7 +13,7 @@
"directory": "unrar",
"name": "unrar",
"checksum": "07a6371cc7db8493352739ce26b19ea1",
- "url": "https://github.com/morpheus65535/bazarr-binaries/raw/master/bin/Linux/armv5tel/unrar/unrar"
+ "url": "https://github.com/LavX/bazarr-binaries/raw/master/bin/Linux/armv5tel/unrar/unrar"
},
{
"system": "Linux",
@@ -21,7 +21,7 @@
"directory": "ffmpeg",
"name": "ffmpeg",
"checksum": "43910b9df223a772402830461a80e9a1",
- "url": "https://github.com/morpheus65535/bazarr-binaries/raw/master/bin/Linux/i386/ffmpeg/ffmpeg"
+ "url": "https://github.com/LavX/bazarr-binaries/raw/master/bin/Linux/i386/ffmpeg/ffmpeg"
},
{
"system": "Linux",
@@ -29,7 +29,7 @@
"directory": "ffmpeg",
"name": "ffprobe",
"checksum": "24ccbd651630c562fbaf17e30a18ce38",
- "url": "https://github.com/morpheus65535/bazarr-binaries/raw/master/bin/Linux/i386/ffmpeg/ffprobe"
+ "url": "https://github.com/LavX/bazarr-binaries/raw/master/bin/Linux/i386/ffmpeg/ffprobe"
},
{
"system": "Linux",
@@ -37,7 +37,7 @@
"directory": "unrar",
"name": "unrar",
"checksum": "f03ae1d4abf871b95142a7248a6cfa3a",
- "url": "https://github.com/morpheus65535/bazarr-binaries/raw/master/bin/Linux/i386/unrar/unrar"
+ "url": "https://github.com/LavX/bazarr-binaries/raw/master/bin/Linux/i386/unrar/unrar"
},
{
"system": "Linux",
@@ -45,7 +45,7 @@
"directory": "ffmpeg",
"name": "ffmpeg",
"checksum": "9fd68f7b80cd1177b94a71455b288131",
- "url": "https://github.com/morpheus65535/bazarr-binaries/raw/master/bin/Linux/x86_64/ffmpeg/ffmpeg"
+ "url": "https://github.com/LavX/bazarr-binaries/raw/master/bin/Linux/x86_64/ffmpeg/ffmpeg"
},
{
"system": "Linux",
@@ -53,7 +53,7 @@
"directory": "ffmpeg",
"name": "ffprobe",
"checksum": "29fdc34d9c7ad07c8f75e83d8fb5965b",
- "url": "https://github.com/morpheus65535/bazarr-binaries/raw/master/bin/Linux/x86_64/ffmpeg/ffprobe"
+ "url": "https://github.com/LavX/bazarr-binaries/raw/master/bin/Linux/x86_64/ffmpeg/ffprobe"
},
{
"system": "Linux",
@@ -61,7 +61,7 @@
"directory": "unrar",
"name": "unrar",
"checksum": "2a63e80d50c039e1fba01de7dcfa6432",
- "url": "https://github.com/morpheus65535/bazarr-binaries/raw/master/bin/Linux/x86_64/unrar/unrar"
+ "url": "https://github.com/LavX/bazarr-binaries/raw/master/bin/Linux/x86_64/unrar/unrar"
},
{
"system": "MacOSX",
@@ -69,7 +69,7 @@
"directory": "ffmpeg",
"name": "ffmpeg",
"checksum": "af1723314ba5ca9c70ef35dc8b5c2260",
- "url": "https://github.com/morpheus65535/bazarr-binaries/raw/master/bin/MacOSX/i386/ffmpeg/ffmpeg"
+ "url": "https://github.com/LavX/bazarr-binaries/raw/master/bin/MacOSX/i386/ffmpeg/ffmpeg"
},
{
"system": "MacOSX",
@@ -77,7 +77,7 @@
"directory": "ffmpeg",
"name": "ffprobe",
"checksum": "d81468cebd6630450d2f5a17720b4504",
- "url": "https://github.com/morpheus65535/bazarr-binaries/raw/master/bin/MacOSX/i386/ffmpeg/ffprobe"
+ "url": "https://github.com/LavX/bazarr-binaries/raw/master/bin/MacOSX/i386/ffmpeg/ffprobe"
},
{
"system": "MacOSX",
@@ -85,7 +85,7 @@
"directory": "unrar",
"name": "unrar",
"checksum": "47de1fed0a5d4f7bac4d8f7557926398",
- "url": "https://github.com/morpheus65535/bazarr-binaries/raw/master/bin/MacOSX/i386/unrar/unrar"
+ "url": "https://github.com/LavX/bazarr-binaries/raw/master/bin/MacOSX/i386/unrar/unrar"
},
{
"system": "MacOSX",
@@ -93,7 +93,7 @@
"directory": "ffmpeg",
"name": "ffmpeg",
"checksum": "af1723314ba5ca9c70ef35dc8b5c2260",
- "url": "https://github.com/morpheus65535/bazarr-binaries/raw/master/bin/MacOSX/x86_64/ffmpeg/ffmpeg"
+ "url": "https://github.com/LavX/bazarr-binaries/raw/master/bin/MacOSX/x86_64/ffmpeg/ffmpeg"
},
{
"system": "MacOSX",
@@ -101,7 +101,7 @@
"directory": "ffmpeg",
"name": "ffprobe",
"checksum": "d81468cebd6630450d2f5a17720b4504",
- "url": "https://github.com/morpheus65535/bazarr-binaries/raw/master/bin/MacOSX/x86_64/ffmpeg/ffprobe"
+ "url": "https://github.com/LavX/bazarr-binaries/raw/master/bin/MacOSX/x86_64/ffmpeg/ffprobe"
},
{
"system": "MacOSX",
@@ -109,7 +109,7 @@
"directory": "unrar",
"name": "unrar",
"checksum": "8d5b3d5d6be2c14b74b02767430ade9c",
- "url": "https://github.com/morpheus65535/bazarr-binaries/raw/master/bin/MacOSX/x86_64/unrar/unrar"
+ "url": "https://github.com/LavX/bazarr-binaries/raw/master/bin/MacOSX/x86_64/unrar/unrar"
},
{
"system": "MacOSX",
@@ -117,7 +117,7 @@
"directory": "ffmpeg",
"name": "ffmpeg",
"checksum": "af1723314ba5ca9c70ef35dc8b5c2260",
- "url": "https://github.com/morpheus65535/bazarr-binaries/raw/master/bin/MacOSX/x86_64/ffmpeg/ffmpeg"
+ "url": "https://github.com/LavX/bazarr-binaries/raw/master/bin/MacOSX/x86_64/ffmpeg/ffmpeg"
},
{
"system": "MacOSX",
@@ -125,7 +125,7 @@
"directory": "ffmpeg",
"name": "ffprobe",
"checksum": "d81468cebd6630450d2f5a17720b4504",
- "url": "https://github.com/morpheus65535/bazarr-binaries/raw/master/bin/MacOSX/x86_64/ffmpeg/ffprobe"
+ "url": "https://github.com/LavX/bazarr-binaries/raw/master/bin/MacOSX/x86_64/ffmpeg/ffprobe"
},
{
"system": "MacOSX",
@@ -133,7 +133,7 @@
"directory": "unrar",
"name": "unrar",
"checksum": "8d5b3d5d6be2c14b74b02767430ade9c",
- "url": "https://github.com/morpheus65535/bazarr-binaries/raw/master/bin/MacOSX/x86_64/unrar/unrar"
+ "url": "https://github.com/LavX/bazarr-binaries/raw/master/bin/MacOSX/x86_64/unrar/unrar"
},
{
"system": "Windows",
@@ -141,7 +141,7 @@
"directory": "ffmpeg",
"name": "ffmpeg.exe",
"checksum": "43f89a5172c377e05ebb4c26a498a366",
- "url": "https://github.com/morpheus65535/bazarr-binaries/raw/master/bin/Windows/i386/ffmpeg/ffmpeg.exe"
+ "url": "https://github.com/LavX/bazarr-binaries/raw/master/bin/Windows/i386/ffmpeg/ffmpeg.exe"
},
{
"system": "Windows",
@@ -149,7 +149,7 @@
"directory": "ffmpeg",
"name": "ffprobe.exe",
"checksum": "a0d88aa85624070a8ab65ed99c214bea",
- "url": "https://github.com/morpheus65535/bazarr-binaries/raw/master/bin/Windows/i386/ffmpeg/ffprobe.exe"
+ "url": "https://github.com/LavX/bazarr-binaries/raw/master/bin/Windows/i386/ffmpeg/ffprobe.exe"
},
{
"system": "Windows",
@@ -157,6 +157,6 @@
"directory": "unrar",
"name": "unrar.exe",
"checksum": "4611a5b3f70a8d6c40776e0bfa3b3f36",
- "url": "https://github.com/morpheus65535/bazarr-binaries/raw/master/bin/Windows/i386/unrar/UnRAR.exe"
+ "url": "https://github.com/LavX/bazarr-binaries/raw/master/bin/Windows/i386/unrar/UnRAR.exe"
}
]
diff --git a/bazarr/utilities/cache.py b/bazarr/utilities/cache.py
index a43d536277..02e86fe191 100644
--- a/bazarr/utilities/cache.py
+++ b/bazarr/utilities/cache.py
@@ -11,9 +11,10 @@
from app.jobs_queue import jobs_queue
-def cache_maintenance(job_id=None):
+def cache_maintenance(job_id=None, wait_for_completion=False):
if not job_id:
- jobs_queue.add_job_from_function("Performing Cache Maintenance", is_progress=False)
+ jobs_queue.add_job_from_function("Performing Cache Maintenance", is_progress=False,
+ wait_for_completion=wait_for_completion)
return
main_cache_validity = 14 # days
diff --git a/bazarr/utilities/filesystem.py b/bazarr/utilities/filesystem.py
index fea29917a3..0fa919fa43 100644
--- a/bazarr/utilities/filesystem.py
+++ b/bazarr/utilities/filesystem.py
@@ -1,8 +1,27 @@
# coding=utf-8
import os
+import logging
import string
+# System directories that should not be browsable (Linux/macOS)
+# Note: /run is NOT blocked because /run/media is used for automounted
+# removable media on many Linux distributions (GNOME, KDE, etc.)
+_BLOCKED_PATHS = {
+ '/proc', '/sys', '/dev', '/snap',
+ '/boot', '/lost+found', '/swapfile',
+ '/etc', '/root', '/tmp',
+}
+
+
+def _is_path_blocked(path):
+ """Check if a path falls within a blocked system directory."""
+ real = os.path.realpath(path)
+ for blocked in _BLOCKED_PATHS:
+ if real == blocked or real.startswith(blocked + os.sep):
+ return True
+ return False
+
def browse_bazarr_filesystem(path='#'):
if path == '#' or path == '/' or path == '':
@@ -16,11 +35,16 @@ def browse_bazarr_filesystem(path='#'):
path = "/"
dir_list = [f for f in os.listdir(path) if os.path.isdir(os.path.join(path, f))]
else:
+ if _is_path_blocked(path):
+ logging.warning(f'Filesystem browse blocked for restricted path: {path}')
+ return {'directories': [], 'parent': os.path.dirname(path)}
dir_list = [f for f in os.listdir(path) if os.path.isdir(os.path.join(path, f))]
data = []
for item in dir_list:
full_path = os.path.join(path, item, '')
+ if _is_path_blocked(full_path):
+ continue
item = {
"name": item,
"path": full_path
diff --git a/bazarr/utilities/health.py b/bazarr/utilities/health.py
index 003414699f..019beda35a 100644
--- a/bazarr/utilities/health.py
+++ b/bazarr/utilities/health.py
@@ -14,9 +14,9 @@
from radarr.rootfolder import check_radarr_rootfolder
-def check_health(job_id=None):
+def check_health(job_id=None, wait_for_completion=False):
if not job_id:
- jobs_queue.add_job_from_function("Checking Health", is_progress=False)
+ jobs_queue.add_job_from_function("Checking Health", is_progress=False, wait_for_completion=wait_for_completion)
return
if settings.general.use_sonarr:
diff --git a/bazarr/utilities/helper.py b/bazarr/utilities/helper.py
index e8378bd847..cda9d05eff 100644
--- a/bazarr/utilities/helper.py
+++ b/bazarr/utilities/helper.py
@@ -1,6 +1,7 @@
# coding=utf-8
import os
+import hmac
import logging
import hashlib
@@ -10,13 +11,58 @@
from app.config import settings
+PBKDF2_ITERATIONS = 600_000
+
+
+def hash_password(pw):
+ salt = os.urandom(16)
+ hashed = hashlib.pbkdf2_hmac('sha256', f"{pw}".encode('utf-8'), salt, PBKDF2_ITERATIONS)
+ return 'pbkdf2:' + salt.hex() + ':' + hashed.hex()
+
+
+def _is_legacy_md5(stored_hash):
+ return not stored_hash.startswith('pbkdf2:')
+
+
+def _verify_password(pw, stored_hash):
+ if stored_hash.startswith('pbkdf2:'):
+ try:
+ _, salt_hex, hash_hex = stored_hash.split(':', 2)
+ salt = bytes.fromhex(salt_hex)
+ expected = bytes.fromhex(hash_hex)
+ except (ValueError, TypeError):
+ logging.error('Corrupted PBKDF2 password hash in config. Re-set your password in settings.')
+ return False
+ actual = hashlib.pbkdf2_hmac('sha256', f"{pw}".encode('utf-8'), salt, PBKDF2_ITERATIONS)
+ return hmac.compare_digest(actual, expected)
+ else:
+ return hmac.compare_digest(
+ hashlib.md5(f"{pw}".encode('utf-8')).hexdigest(),
+ stored_hash
+ )
+
+
+def upgrade_password_hash(pw):
+ new_hash = hash_password(pw)
+ old_hash = settings.auth.password
+ settings.auth.password = new_hash
+ try:
+ from app.config import write_config
+ write_config()
+ logging.info('Upgraded password hash from MD5 to PBKDF2-SHA256')
+ except Exception:
+ settings.auth.password = old_hash
+ logging.exception('Failed to persist password hash upgrade, reverted to previous hash')
+ raise
+
+
def check_credentials(user, pw, request, log_success=True):
forwarded_for_ip_addr = request.environ.get('HTTP_X_FORWARDED_FOR')
real_ip_addr = request.environ.get('HTTP_X_REAL_IP')
ip_addr = forwarded_for_ip_addr or real_ip_addr or request.remote_addr
username = settings.auth.username
password = settings.auth.password
- if hashlib.md5(f"{pw}".encode('utf-8')).hexdigest() == password and user == username:
+ if user == username and _verify_password(pw, password):
if log_success:
logging.info(f'Successful authentication from {ip_addr} for user {user}')
return True
@@ -25,6 +71,11 @@ def check_credentials(user, pw, request, log_success=True):
return False
+def needs_password_upgrade():
+ password = settings.auth.password
+ return bool(password) and _is_legacy_md5(password)
+
+
def get_subtitle_destination_folder():
fld_custom = str(settings.general.subfolder_custom).strip() if (settings.general.subfolder_custom and
settings.general.subfolder != 'current') else None
diff --git a/bazarr/utilities/plex_utils.py b/bazarr/utilities/plex_utils.py
index 487ca28440..1ea147be89 100644
--- a/bazarr/utilities/plex_utils.py
+++ b/bazarr/utilities/plex_utils.py
@@ -2,7 +2,7 @@
import logging
import requests
-from app.config import settings
+from app.config import settings, get_ssl_verify
def get_plex_libraries_with_paths():
@@ -30,7 +30,7 @@ def get_plex_libraries_with_paths():
f"{server_url}/library/sections",
headers={'X-Plex-Token': decrypted_token, 'Accept': 'application/json'},
timeout=5,
- verify=False
+ verify=get_ssl_verify('plex')
)
if response.status_code != 200:
@@ -73,7 +73,7 @@ def _get_library_locations(server_url, section_key, token):
f"{server_url}/library/sections/{section_key}",
headers={'X-Plex-Token': token, 'Accept': 'application/json'},
timeout=5,
- verify=False
+ verify=get_ssl_verify('plex')
)
if response.status_code == 200:
diff --git a/bazarr/utilities/post_processing.py b/bazarr/utilities/post_processing.py
index d68ac9de73..094d8cb034 100644
--- a/bazarr/utilities/post_processing.py
+++ b/bazarr/utilities/post_processing.py
@@ -3,43 +3,51 @@
import os
import re
import sys
+import shlex
+import subprocess
import logging
from app.config import settings
-# Wraps the input string within quotes & escapes the string
def _escape(in_str):
- raw_map = {8: r'\\b', 7: r'\\a', 12: r'\\f', 10: r'\\n', 13: r'\\r', 9: r'\\t', 11: r'\\v', 34: r'\"', 92: r'\\'}
- raw_str = r''.join(raw_map.get(ord(i), i) for i in in_str)
- return f"\"{raw_str}\""
+ s = str(in_str) if in_str is not None else ''
+ if os.name == 'nt':
+ # cmd.exe: use subprocess.list2cmdline for proper Windows quoting
+ return subprocess.list2cmdline([s])
+ return shlex.quote(s)
+
+
+def _pp_sub(pattern, value, command):
+ """Substitute a placeholder, escaping backslashes for re.sub replacement."""
+ escaped = _escape(value)
+ return re.sub(pattern, lambda _: escaped, command)
def pp_replace(pp_command, episode, subtitles, language, language_code2, language_code3, episode_language,
episode_language_code2, episode_language_code3, score, subtitle_id, provider, uploader,
release_info, series_id, episode_id):
- pp_command = re.sub(r'[\'"]?{{directory}}[\'"]?', _escape(os.path.dirname(episode)), pp_command)
- pp_command = re.sub(r'[\'"]?{{episode}}[\'"]?', _escape(episode), pp_command)
- pp_command = re.sub(r'[\'"]?{{episode_name}}[\'"]?', _escape(os.path.splitext(os.path.basename(episode))[0]),
- pp_command)
- pp_command = re.sub(r'[\'"]?{{subtitles}}[\'"]?', _escape(str(subtitles)), pp_command)
- pp_command = re.sub(r'[\'"]?{{subtitles_language}}[\'"]?', _escape(str(language)), pp_command)
- pp_command = re.sub(r'[\'"]?{{subtitles_language_code2}}[\'"]?', _escape(str(language_code2)), pp_command)
- pp_command = re.sub(r'[\'"]?{{subtitles_language_code3}}[\'"]?', _escape(str(language_code3)), pp_command)
- pp_command = re.sub(r'[\'"]?{{subtitles_language_code2_dot}}[\'"]?',
- _escape(str(language_code2).replace(':', '.')), pp_command)
- pp_command = re.sub(r'[\'"]?{{subtitles_language_code3_dot}}[\'"]?',
- _escape(str(language_code3).replace(':', '.')), pp_command)
- pp_command = re.sub(r'[\'"]?{{episode_language}}[\'"]?', _escape(str(episode_language)), pp_command)
- pp_command = re.sub(r'[\'"]?{{episode_language_code2}}[\'"]?', _escape(str(episode_language_code2)), pp_command)
- pp_command = re.sub(r'[\'"]?{{episode_language_code3}}[\'"]?', _escape(str(episode_language_code3)), pp_command)
- pp_command = re.sub(r'[\'"]?{{score}}[\'"]?', _escape(str(score)), pp_command)
- pp_command = re.sub(r'[\'"]?{{subtitle_id}}[\'"]?', _escape(str(subtitle_id)), pp_command)
- pp_command = re.sub(r'[\'"]?{{provider}}[\'"]?', _escape(str(provider)), pp_command)
- pp_command = re.sub(r'[\'"]?{{uploader}}[\'"]?', _escape(str(uploader)), pp_command)
- pp_command = re.sub(r'[\'"]?{{release_info}}[\'"]?', _escape(str(release_info)), pp_command)
- pp_command = re.sub(r'[\'"]?{{series_id}}[\'"]?', _escape(str(series_id)), pp_command)
- pp_command = re.sub(r'[\'"]?{{episode_id}}[\'"]?', _escape(str(episode_id)), pp_command)
+ pp_command = _pp_sub(r'[\'"]?{{directory}}[\'"]?', os.path.dirname(episode), pp_command)
+ pp_command = _pp_sub(r'[\'"]?{{episode}}[\'"]?', episode, pp_command)
+ pp_command = _pp_sub(r'[\'"]?{{episode_name}}[\'"]?', os.path.splitext(os.path.basename(episode))[0], pp_command)
+ pp_command = _pp_sub(r'[\'"]?{{subtitles}}[\'"]?', str(subtitles), pp_command)
+ pp_command = _pp_sub(r'[\'"]?{{subtitles_language}}[\'"]?', str(language), pp_command)
+ pp_command = _pp_sub(r'[\'"]?{{subtitles_language_code2}}[\'"]?', str(language_code2), pp_command)
+ pp_command = _pp_sub(r'[\'"]?{{subtitles_language_code3}}[\'"]?', str(language_code3), pp_command)
+ pp_command = _pp_sub(r'[\'"]?{{subtitles_language_code2_dot}}[\'"]?',
+ str(language_code2).replace(':', '.'), pp_command)
+ pp_command = _pp_sub(r'[\'"]?{{subtitles_language_code3_dot}}[\'"]?',
+ str(language_code3).replace(':', '.'), pp_command)
+ pp_command = _pp_sub(r'[\'"]?{{episode_language}}[\'"]?', str(episode_language), pp_command)
+ pp_command = _pp_sub(r'[\'"]?{{episode_language_code2}}[\'"]?', str(episode_language_code2), pp_command)
+ pp_command = _pp_sub(r'[\'"]?{{episode_language_code3}}[\'"]?', str(episode_language_code3), pp_command)
+ pp_command = _pp_sub(r'[\'"]?{{score}}[\'"]?', str(score), pp_command)
+ pp_command = _pp_sub(r'[\'"]?{{subtitle_id}}[\'"]?', str(subtitle_id), pp_command)
+ pp_command = _pp_sub(r'[\'"]?{{provider}}[\'"]?', str(provider), pp_command)
+ pp_command = _pp_sub(r'[\'"]?{{uploader}}[\'"]?', str(uploader), pp_command)
+ pp_command = _pp_sub(r'[\'"]?{{release_info}}[\'"]?', str(release_info), pp_command)
+ pp_command = _pp_sub(r'[\'"]?{{series_id}}[\'"]?', str(series_id), pp_command)
+ pp_command = _pp_sub(r'[\'"]?{{episode_id}}[\'"]?', str(episode_id), pp_command)
return pp_command
diff --git a/custom_libs/subliminal/extensions.py b/custom_libs/subliminal/extensions.py
index 61843ed410..f3b630b359 100644
--- a/custom_libs/subliminal/extensions.py
+++ b/custom_libs/subliminal/extensions.py
@@ -64,7 +64,7 @@ def register(self, entry_point):
if ep.name in self.names():
raise ValueError('An extension with the same name already exist')
- ext = self._load_one_plugin(ep, False, (), {}, False)
+ ext = self._load_one_plugin(ep, False, (), {})
self.extensions.append(ext)
if self._extensions_by_name is not None:
self._extensions_by_name[ext.name] = ext
diff --git a/custom_libs/subliminal_patch/core.py b/custom_libs/subliminal_patch/core.py
index 6ea2c1bbc7..3d57c2bf5a 100644
--- a/custom_libs/subliminal_patch/core.py
+++ b/custom_libs/subliminal_patch/core.py
@@ -23,8 +23,8 @@
from concurrent.futures import as_completed
from .extensions import provider_registry
-from .exceptions import MustGetBlacklisted
-from .score import compute_score as default_compute_score
+from .exceptions import APIThrottled, MustGetBlacklisted
+from .score import compute_score, MAX_SCORES
from subliminal.utils import hash_napiprojekt, hash_opensubtitles, hash_shooter, hash_thesubdb
from subliminal.video import VIDEO_EXTENSIONS, Video, Episode, Movie
from subliminal.core import guessit, ProviderPool, io, is_windows_special_path, \
@@ -238,6 +238,8 @@ def __init__(self, providers=None, provider_configs=None, blacklist=None, ban_li
self._born = time.time()
+ self.provider_progress_callback = None
+
if not self.throttle_callback:
self.throttle_callback = lambda x, y, ids=None, language=None: x
@@ -376,6 +378,9 @@ def list_subtitles_provider(self, provider, video, languages):
logger.info('Listing subtitles with provider %r and languages %r', provider, to_request)
+ if self.provider_progress_callback:
+ self.provider_progress_callback(provider)
+
try:
results = self[provider].list_subtitles(video, to_request)
seen = []
@@ -412,6 +417,15 @@ def list_subtitles_provider(self, provider, video, languages):
return out
+ except APIThrottled as e:
+ ids = {
+ 'radarrId': video.radarrId if hasattr(video, 'radarrId') else None,
+ 'sonarrSeriesId': video.sonarrSeriesId if hasattr(video, 'sonarrSeriesId') else None,
+ 'sonarrEpisodeId': video.sonarrEpisodeId if hasattr(video, 'sonarrEpisodeId') else None,
+ }
+ logger.warning('Provider %r throttled: %s', provider, e)
+ self.throttle_callback(provider, e, ids=ids, language=list(languages)[0] if len(languages) else None)
+
except Exception as e:
ids = {
'radarrId': video.radarrId if hasattr(video, 'radarrId') else None,
@@ -598,7 +612,7 @@ def download_subtitle(self, subtitle):
return True
def download_best_subtitles(self, subtitles, video, languages, min_score=0, hearing_impaired=False, only_one=False,
- compute_score=None, use_original_format=False):
+ use_original_format=False):
"""Download the best matching subtitles.
patch:
@@ -615,18 +629,15 @@ def download_best_subtitles(self, subtitles, video, languages, min_score=0, hear
:param int min_score: minimum score for a subtitle to be downloaded.
:param bool hearing_impaired: hearing impaired preference.
:param bool only_one: download only one subtitle, not one per language.
- :param compute_score: function that takes `subtitle` and `video` as positional arguments,
- `hearing_impaired` as keyword argument and returns the score.
:param bool use_original_format: preserve original subtitles format
:return: downloaded subtitles.
:rtype: list of :class:`~subliminal.subtitle.Subtitle`
"""
- compute_score = compute_score or default_compute_score
use_hearing_impaired = hearing_impaired in ("prefer", "force HI")
is_episode = isinstance(video, Episode)
- max_score = sum(val for key, val in compute_score._scores['episode' if is_episode else 'movie'].items() if key != "hash")
+ max_score = MAX_SCORES['episode' if is_episode else 'movie']
# sort subtitles by score
unsorted_subtitles = []
@@ -638,7 +649,9 @@ def download_best_subtitles(self, subtitles, video, languages, min_score=0, hear
continue
try:
- matches = s.get_matches(video)
+ matches = s.matches if hasattr(s, 'matches') and isinstance(s.matches, set) and len(s.matches) \
+ else s.get_matches(video)
+
except AttributeError:
logger.error("%r: Match computation failed: %s", s, traceback.format_exc())
continue
@@ -1159,7 +1172,7 @@ def download_subtitles(subtitles, pool_class=ProviderPool, **kwargs):
pool.download_subtitle(subtitle)
-def download_best_subtitles(videos, languages, min_score=0, hearing_impaired=False, only_one=False, compute_score=None,
+def download_best_subtitles(videos, languages, min_score=0, hearing_impaired=False, only_one=False,
pool_class=ProviderPool, throttle_time=0, **kwargs):
r"""List and download the best matching subtitles.
@@ -1172,8 +1185,6 @@ def download_best_subtitles(videos, languages, min_score=0, hearing_impaired=Fal
:param int min_score: minimum score for a subtitle to be downloaded.
:param bool hearing_impaired: hearing impaired preference.
:param bool only_one: download only one subtitle, not one per language.
- :param compute_score: function that takes `subtitle` and `video` as positional arguments,
- `hearing_impaired` as keyword argument and returns the score.
:param pool_class: class to use as provider pool.
:type pool_class: :class:`ProviderPool`, :class:`AsyncProviderPool` or similar
:param \*\*kwargs: additional parameters for the provided `pool_class` constructor.
diff --git a/custom_libs/subliminal_patch/core_persistent.py b/custom_libs/subliminal_patch/core_persistent.py
index 84568d58fa..888d65f420 100644
--- a/custom_libs/subliminal_patch/core_persistent.py
+++ b/custom_libs/subliminal_patch/core_persistent.py
@@ -49,8 +49,8 @@ def download_best_subtitles(
min_score=0,
hearing_impaired=False,
only_one=False,
- compute_score=None,
use_original_format=False,
+ use_provider_priority=True,
**kwargs
):
downloaded_subtitles = defaultdict(list)
@@ -70,14 +70,17 @@ def download_best_subtitles(
# download best subtitles
for video in checked_videos:
logger.info("Downloading best subtitles for %r", video)
+ if use_provider_priority:
+ listed = pool_instance.list_subtitles_prioritized(video, languages - video.subtitle_languages, min_score=min_score)
+ else:
+ listed = pool_instance.list_subtitles(video, languages - video.subtitle_languages)
subtitles = pool_instance.download_best_subtitles(
- pool_instance.list_subtitles_prioritized(video, languages - video.subtitle_languages, min_score=min_score),
+ listed,
video,
languages,
min_score=min_score,
hearing_impaired=hearing_impaired,
only_one=only_one,
- compute_score=compute_score,
use_original_format=use_original_format,
)
logger.info("Downloaded %d subtitle(s)", len(subtitles))
diff --git a/custom_libs/subliminal_patch/exceptions.py b/custom_libs/subliminal_patch/exceptions.py
index e5857beef5..0714f23080 100644
--- a/custom_libs/subliminal_patch/exceptions.py
+++ b/custom_libs/subliminal_patch/exceptions.py
@@ -10,7 +10,9 @@ class TooManyRequests(ProviderError):
class APIThrottled(ProviderError):
- pass
+ def __init__(self, *args, retry_after=None, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.retry_after = retry_after
class ForbiddenError(ProviderError):
diff --git a/custom_libs/subliminal_patch/providers/legendasnet.py b/custom_libs/subliminal_patch/providers/legendasnet.py
index 2bda948354..bc1a2ddf66 100644
--- a/custom_libs/subliminal_patch/providers/legendasnet.py
+++ b/custom_libs/subliminal_patch/providers/legendasnet.py
@@ -107,9 +107,19 @@ def login(self):
"password": self.password
})
- response = self.session.request("POST", self.server_url() + 'login', data=payload, headers=headersList)
- if response.status_code != 200:
- raise ConfigurationError('Failed to login and retrieve access token')
+ response = self.retry(
+ lambda: self.session.request("POST", self.server_url() + 'login', data=payload, headers=headersList, timeout=30),
+ amount=retry_amount,
+ retry_timeout=retry_timeout
+ )
+
+ if response.status_code == 429:
+ raise APIThrottled('Too many requests')
+ elif response.status_code in (401, 403):
+ raise ConfigurationError('Invalid username or password')
+ elif response.status_code != 200:
+ response.raise_for_status()
+
self.access_token = response.json().get('access_token')
if not self.access_token:
raise ConfigurationError('Access token not found in login response')
@@ -164,7 +174,7 @@ def query(self, languages, video):
raise ProviderError("Endpoint not found")
elif res.status_code == 429:
raise APIThrottled("Too many requests")
- elif res.status_code == 403:
+ elif res.status_code in (401, 403):
raise ConfigurationError("Invalid access token")
elif res.status_code != 200:
res.raise_for_status()
@@ -241,7 +251,7 @@ def download_subtitle(self, subtitle):
if r.status_code == 429:
raise DownloadLimitExceeded("Daily download limit exceeded")
- elif r.status_code == 403:
+ elif r.status_code in (401, 403):
raise ConfigurationError("Invalid access token")
elif r.status_code != 200:
r.raise_for_status()
diff --git a/custom_libs/subliminal_patch/providers/opensubtitles.py b/custom_libs/subliminal_patch/providers/opensubtitles.py
index 7fa42577cf..01af0fcac8 100644
--- a/custom_libs/subliminal_patch/providers/opensubtitles.py
+++ b/custom_libs/subliminal_patch/providers/opensubtitles.py
@@ -114,11 +114,17 @@ def id(self):
@property
def series_name(self):
- return self.series_re.match(self.movie_name).group('series_name')
+ match = self.series_re.match(self.movie_name)
+ if match:
+ return match.group('series_name')
+ return self.movie_name
@property
def series_title(self):
- return self.series_re.match(self.movie_name).group('series_title')
+ match = self.series_re.match(self.movie_name)
+ if match:
+ return match.group('series_title')
+ return self.movie_name
def get_matches(self, video):
matches = set()
@@ -132,7 +138,7 @@ def get_matches(self, video):
if video.series and sanitize(self.series_name) == sanitize(video.series):
matches.add('series')
# year
- if video.original_series and self.movie_year is None or video.year and video.year == self.movie_year:
+ if (video.original_series and self.movie_year is None) or (video.year and video.year == self.movie_year):
matches.add('year')
# season
if video.season and self.series_season == video.season:
@@ -224,7 +230,7 @@ def __init__(self, language, hearing_impaired, page_link, subtitle_id, matched_b
def get_fps(self):
try:
return float(self.fps)
- except:
+ except (ValueError, TypeError):
return None
def get_matches(self, video, hearing_impaired=False):
@@ -250,7 +256,7 @@ def get_matches(self, video, hearing_impaired=False):
sub_fps = None
try:
sub_fps = float(self.fps)
- except ValueError:
+ except (ValueError, TypeError):
pass
# video has fps info, sub also, and sub's fps is greater than 0
@@ -397,8 +403,18 @@ def use_token_or_login(self, func):
def initialize(self):
if self.use_web_scraper:
- # Skip authentication for scraper mode
- logger.debug("Web scraper mode - skipping authentication")
+ # Verify scraper service is reachable before searching
+ try:
+ base_url = self.scraper_service_url.rstrip('/')
+ if not base_url.startswith(('http://', 'https://')):
+ base_url = f'http://{base_url}'
+ resp = requests.get(f'{base_url}/health', timeout=5)
+ resp.raise_for_status()
+ logger.info("Scraper service at %s is healthy", self.scraper_service_url)
+ except Exception as e:
+ raise ServiceUnavailable(
+ f'OpenSubtitles scraper at {self.scraper_service_url} is not reachable: {e}'
+ )
self.server = None
self.token = None
return
@@ -610,7 +626,7 @@ def checked(fn, raise_api_limit=False):
status_code = 506
else:
status_code = int(response['status'][:3])
- except:
+ except Exception:
status_code = None
if status_code == 401:
diff --git a/custom_libs/subliminal_patch/providers/opensubtitles_scraper.py b/custom_libs/subliminal_patch/providers/opensubtitles_scraper.py
index 75677ed69a..dfe849e074 100644
--- a/custom_libs/subliminal_patch/providers/opensubtitles_scraper.py
+++ b/custom_libs/subliminal_patch/providers/opensubtitles_scraper.py
@@ -1,167 +1,224 @@
# coding=utf-8
"""
-OpenSubtitles Web Scraper Implementation
+OpenSubtitles Web Scraper Integration (v1 API)
-This module provides HTTP-based communication with OpenSubtitles scraper services,
-bypassing the need for traditional API authentication.
+Uses the scraper service's REST API directly instead of the legacy
+compatibility endpoint, passing all available metadata for best results.
"""
import base64
import logging
import requests
-import json
+from subliminal.video import Episode
from subzero.language import Language
-from subliminal.exceptions import ServiceUnavailable, ConfigurationError
+from subliminal.exceptions import ServiceUnavailable
from subliminal_patch.exceptions import APIThrottled
logger = logging.getLogger(__name__)
+# Map opensubtitles 3-letter codes to 2-letter codes for the scraper
+_LANG_3_TO_2 = {
+ 'eng': 'en', 'hun': 'hu', 'spa': 'es', 'fre': 'fr', 'ger': 'de',
+ 'ita': 'it', 'por': 'pt', 'rus': 'ru', 'chi': 'zh', 'jpn': 'ja',
+ 'kor': 'ko', 'ara': 'ar', 'dut': 'nl', 'pol': 'pl', 'tur': 'tr',
+ 'swe': 'sv', 'nor': 'no', 'dan': 'da', 'fin': 'fi', 'cze': 'cs',
+ 'rum': 'ro', 'hrv': 'hr', 'srp': 'sr', 'bul': 'bg', 'gre': 'el',
+ 'heb': 'he', 'tha': 'th', 'vie': 'vi', 'ind': 'id', 'may': 'ms',
+ 'per': 'fa', 'ukr': 'uk', 'est': 'et', 'lav': 'lv', 'lit': 'lt',
+ 'slv': 'sl', 'slo': 'sk', 'ice': 'is', 'cat': 'ca', 'bos': 'bs',
+ 'glg': 'gl', 'baq': 'eu', 'geo': 'ka', 'mac': 'mk', 'alb': 'sq',
+}
+
class OpenSubtitlesScraperMixin:
"""
- Mixin class that provides web scraper functionality for OpenSubtitles provider.
- This allows communication with scraper services that provide OpenSubtitles-compatible APIs.
+ Mixin class providing web scraper functionality via the v1 REST API.
"""
-
- def _query_scraper(self, video, languages, hash=None, size=None, imdb_id=None, query=None, season=None,
- episode=None, tag=None, use_tag_search=False, only_foreign=False, also_foreign=False):
- """
- Query the scraper service for subtitles.
-
- This method converts the traditional OpenSubtitles XML-RPC format to HTTP requests
- that are compatible with scraper services.
- """
- logger.info('Querying scraper service at %s', self.scraper_service_url)
-
- # Build search criteria similar to the API version
- criteria = []
- if hash and size:
- criteria.append({'moviehash': hash, 'moviebytesize': str(size)})
- if use_tag_search and tag:
- criteria.append({'tag': tag})
- if imdb_id:
- if season and episode:
- criteria.append({'imdbid': imdb_id[2:], 'season': season, 'episode': episode})
- else:
- criteria.append({'imdbid': imdb_id[2:]})
-
- if not criteria:
- raise ValueError('Not enough information')
- # Add language information
- for criterion in criteria:
- criterion['sublanguageid'] = ','.join(sorted(l.opensubtitles for l in languages))
+ def _get_scraper_base_url(self):
+ base_url = self.scraper_service_url.rstrip('/')
+ if not base_url.startswith(('http://', 'https://')):
+ base_url = f'http://{base_url}'
+ return base_url
+
+ def _scraper_request(self, endpoint, data):
+ """Make a POST request to the scraper service."""
+ url = f"{self._get_scraper_base_url()}{endpoint}"
+ headers = {
+ 'Content-Type': 'application/json',
+ 'User-Agent': 'Bazarr-OpenSubtitles-Scraper/2.0'
+ }
+
+ logger.debug('Scraper request: %s %s', url, data)
+
+ response = requests.post(url, json=data, headers=headers, timeout=120)
+
+ if response.status_code in (429, 503):
+ retry_after_str = response.headers.get("Retry-After")
+ response.close()
+ retry_after = int(retry_after_str) if retry_after_str and retry_after_str.isdigit() else None
+ msg = "Scraper service busy"
+ if retry_after:
+ msg = f"{msg}, retry after {retry_after}s"
+ raise APIThrottled(msg, retry_after=retry_after)
+
+ response.raise_for_status()
+ result = response.json()
+ response.close()
+ return result
+
+ def _query_scraper(self, video, languages, hash=None, size=None, imdb_id=None, query=None,
+ season=None, episode=None, tag=None, use_tag_search=False,
+ only_foreign=False, also_foreign=False):
+ """Query the scraper v1 API with full metadata."""
+ logger.info('Querying scraper v1 API at %s', self.scraper_service_url)
+
+ is_episode = isinstance(video, Episode)
+
+ # Build the best possible query string from available info
+ search_query = ''
+ if query and isinstance(query, list) and query[0]:
+ search_query = query[0]
+ elif query and isinstance(query, str):
+ search_query = query
+ elif hasattr(video, 'series') and video.series:
+ search_query = video.series
+ elif hasattr(video, 'title') and video.title:
+ search_query = video.title
+
+ # Get year from video
+ year = getattr(video, 'year', None)
try:
- # Make HTTP request to scraper service
- response = self._make_scraper_request('/search', {
- 'criteria': criteria,
- 'only_foreign': only_foreign,
- 'also_foreign': also_foreign
+ # Step 1: Search for the movie/show
+ search_endpoint = '/api/v1/search/tv' if is_episode else '/api/v1/search/movies'
+ search_data = {
+ 'query': search_query,
+ 'imdb_id': imdb_id if imdb_id else None,
+ 'year': year,
+ 'kind': 'episode' if is_episode else 'movie',
+ }
+
+ logger.info('Scraper search: %s query=%r imdb=%s year=%s',
+ search_endpoint, search_query, imdb_id, year)
+
+ search_response = self._scraper_request(search_endpoint, search_data)
+
+ results = search_response.get('results', [])
+ if not results:
+ logger.info('Scraper: no results from %s for %r', search_endpoint, search_query)
+ return []
+
+ # Step 2: Select best matching result
+ best_result = self._select_best_result(results, imdb_id, search_query, year)
+ if not best_result:
+ logger.info('Scraper: no matching result for imdb=%s query=%r', imdb_id, search_query)
+ return []
+
+ movie_url = best_result.get('url')
+ if not movie_url:
+ logger.warning('Scraper: search result has no URL')
+ return []
+
+ logger.info('Scraper: selected result: %s (%s) - %s',
+ best_result.get('title'), best_result.get('year'), movie_url)
+
+ # Step 3: Get subtitle listings
+ lang_codes = []
+ for lang in languages:
+ code_3 = lang.opensubtitles
+ code_2 = _LANG_3_TO_2.get(code_3, code_3)
+ lang_codes.append(code_2)
+
+ subs_response = self._scraper_request('/api/v1/subtitles', {
+ 'movie_url': movie_url,
+ 'languages': lang_codes,
})
-
- if not response or not response.get('data'):
- logger.info('No subtitles found from scraper service')
+
+ subtitle_list = subs_response.get('subtitles', [])
+ if not subtitle_list:
+ logger.info('Scraper: no subtitles found at %s', movie_url)
return []
-
- return self._parse_scraper_response(response['data'], languages, only_foreign, also_foreign, video)
-
+
+ logger.info('Scraper: found %d subtitles, parsing...', len(subtitle_list))
+
+ # Step 4: Convert to bazarr subtitle objects
+ return self._parse_v1_subtitles(
+ subtitle_list, best_result, languages, season, episode,
+ only_foreign, also_foreign, video, imdb_id,
+ hash=hash, search_query=search_query
+ )
+
except APIThrottled:
raise
except requests.RequestException as e:
logger.error('Scraper service request failed: %s', e)
raise ServiceUnavailable(f'Scraper service unavailable: {e}')
except Exception as e:
- logger.error('Unexpected error querying scraper service: %s', e)
+ logger.error('Unexpected error querying scraper: %s', e)
raise ServiceUnavailable(f'Scraper service error: {e}')
- def _make_scraper_request(self, endpoint, data):
- """
- Make an HTTP request to the scraper service.
-
- Args:
- endpoint: API endpoint (e.g., '/search', '/download')
- data: Request payload
-
- Returns:
- dict: JSON response from scraper service
- """
- # Ensure URL has proper protocol scheme
- base_url = self.scraper_service_url.rstrip('/')
- if not base_url.startswith(('http://', 'https://')):
- base_url = f'http://{base_url}'
-
- url = f"{base_url}{endpoint}"
-
- headers = {
- 'Content-Type': 'application/json',
- 'User-Agent': 'Bazarr-OpenSubtitles-Scraper/1.0'
- }
-
- logger.debug('Making scraper request to %s with data: %s', url, data)
-
- response = requests.post(
- url,
- json=data,
- headers=headers,
- timeout=120 # Increased timeout for scraper service (IMDB lookups + page navigation)
- )
+ def _select_best_result(self, results, imdb_id, query, year):
+ """Select the best search result by matching IMDB ID, title, and year."""
+ if not results:
+ return None
- if response.status_code in (429, 503):
- retry_after = response.headers.get("Retry-After")
- response.close()
- message = "Scraper service busy"
- if retry_after:
- message = f"{message}, retry after {retry_after}s"
- raise APIThrottled(message)
+ # Exact IMDB match is best
+ if imdb_id:
+ for r in results:
+ if r.get('imdb_id') == imdb_id:
+ return r
- response.raise_for_status()
- return response.json()
-
- def _parse_scraper_response(self, data, languages, only_foreign, also_foreign, video):
- """
- Parse the scraper service response and create subtitle objects.
-
- Args:
- data: Response data from scraper service
- languages: Requested languages
- only_foreign: Only foreign/forced subtitles
- also_foreign: Include foreign/forced subtitles
- video: Video object for matching
-
- Returns:
- list: List of OpenSubtitlesSubtitle objects
- """
+ # Score remaining results
+ scored = []
+ query_lower = (query or '').lower().strip()
+ for r in results:
+ score = 0
+ title_lower = (r.get('title') or '').lower().strip()
+
+ # Title matching
+ if query_lower and title_lower:
+ if title_lower == query_lower:
+ score += 10
+ elif query_lower in title_lower or title_lower in query_lower:
+ score += 5
+
+ # Year matching
+ if year and r.get('year') == year:
+ score += 3
+
+ # Prefer results with more subtitles
+ score += min(r.get('subtitle_count', 0) / 100, 2)
+
+ scored.append((score, r))
+
+ scored.sort(key=lambda x: x[0], reverse=True)
+ return scored[0][1] if scored else results[0]
+
+ def _parse_v1_subtitles(self, subtitle_list, search_result, languages, season, episode,
+ only_foreign, also_foreign, video, imdb_id,
+ hash=None, search_query=None):
+ """Convert v1 API subtitle objects to bazarr OpenSubtitlesSubtitle objects."""
subtitles = []
-
- for subtitle_item in data:
+ series_title = search_result.get('title', '')
+ result_year = search_result.get('year')
+ result_imdb = search_result.get('imdb_id', '')
+
+ for sub in subtitle_list:
try:
- # Parse subtitle item (similar to API version)
- language = Language.fromopensubtitles(subtitle_item['SubLanguageID'])
- hearing_impaired = bool(int(subtitle_item.get('SubHearingImpaired', 0)))
- page_link = subtitle_item.get('SubtitlesLink', '')
- subtitle_id = int(subtitle_item['IDSubtitleFile'])
- matched_by = subtitle_item.get('MatchedBy', 'hash')
- movie_kind = subtitle_item.get('MovieKind', 'movie')
- hash = subtitle_item.get('MovieHash', '')
- movie_name = subtitle_item.get('MovieName', '')
- movie_release_name = subtitle_item.get('MovieReleaseName', '')
- movie_year = int(subtitle_item['MovieYear']) if subtitle_item.get('MovieYear') else None
-
- # Handle IMDB ID
- if subtitle_item.get('SeriesIMDBParent'):
- movie_imdb_id = 'tt' + subtitle_item['SeriesIMDBParent']
- elif subtitle_item.get('IDMovieImdb'):
- movie_imdb_id = 'tt' + subtitle_item['IDMovieImdb']
- else:
- movie_imdb_id = None
-
- movie_fps = subtitle_item.get('MovieFPS')
- series_season = int(subtitle_item['SeriesSeason']) if subtitle_item.get('SeriesSeason') else None
- series_episode = int(subtitle_item['SeriesEpisode']) if subtitle_item.get('SeriesEpisode') else None
- filename = subtitle_item.get('SubFileName', '')
- encoding = subtitle_item.get('SubEncoding')
- foreign_parts_only = bool(int(subtitle_item.get('SubForeignPartsOnly', 0)))
+ # Map 2-letter language back to opensubtitles format
+ lang_2 = sub.get('language', '')
+ try:
+ language = Language.fromietf(lang_2)
+ except Exception:
+ try:
+ language = Language(lang_2)
+ except Exception:
+ logger.debug('Skipping subtitle with unknown language: %s', lang_2)
+ continue
+
+ hearing_impaired = sub.get('hearing_impaired', False)
+ foreign_parts_only = sub.get('forced', False)
# Apply foreign/forced filtering
if only_foreign and not foreign_parts_only:
@@ -171,76 +228,89 @@ def _parse_scraper_response(self, data, languages, only_foreign, also_foreign, v
elif (also_foreign or only_foreign) and foreign_parts_only:
language = Language.rebuild(language, forced=True)
- # Set hearing impaired language
if hearing_impaired:
language = Language.rebuild(language, hi=True)
if language not in languages:
continue
+ subtitle_id = int(sub['subtitle_id'])
+ filename = sub.get('filename', '')
+ release_name = sub.get('release_name', '')
+ fps = sub.get('fps')
+ download_url = sub.get('download_url', '')
+
+ # Build movie_name in the format bazarr expects
+ is_episode = season is not None and episode is not None
+ if is_episode and series_title:
+ movie_name = f'"{series_title}" {release_name}'
+ movie_kind = 'episode'
+ else:
+ movie_name = series_title or release_name
+ movie_kind = 'movie'
+
+ movie_imdb_id = result_imdb if result_imdb else (imdb_id or None)
+ if movie_imdb_id and not movie_imdb_id.startswith('tt'):
+ movie_imdb_id = f'tt{movie_imdb_id}'
+
# IMDB ID matching
- if video.imdb_id and movie_imdb_id and (movie_imdb_id != video.imdb_id):
+ if video.imdb_id and movie_imdb_id and movie_imdb_id != video.imdb_id:
continue
- query_parameters = subtitle_item.get("QueryParameters", {})
+ query_parameters = {
+ 'query': search_query,
+ 'imdb_id': imdb_id,
+ 'season': season,
+ 'episode': episode,
+ }
- # Create subtitle object
subtitle = self.subtitle_class(
- language, hearing_impaired, page_link, subtitle_id, matched_by,
- movie_kind, hash, movie_name, movie_release_name, movie_year, movie_imdb_id,
- series_season, series_episode, query_parameters, filename, encoding,
- movie_fps, skip_wrong_fps=self.skip_wrong_fps
+ language, hearing_impaired, download_url, subtitle_id,
+ 'imdbid' if imdb_id else 'query',
+ movie_kind, hash or '', movie_name, release_name, result_year, movie_imdb_id,
+ season if is_episode else None,
+ episode if is_episode else None,
+ query_parameters, filename, None,
+ fps, skip_wrong_fps=self.skip_wrong_fps
)
-
- # Set additional attributes needed for matching
- subtitle.uploader = subtitle_item.get('UserNickName', 'anonymous')
-
- # For TV series, set movie_name which is used as series_name in matching
- # The series_name property uses movie_name internally
- if movie_kind == 'episode':
- if not movie_name and movie_release_name:
- # Extract series name from release name if movie_name is empty
- # e.g., "The Exchange" Bank of Tomorrow -> "The Exchange"
- import re
- series_match = re.match(r'^"([^"]+)"', movie_release_name)
- if series_match:
- subtitle.movie_name = series_match.group(1)
- else:
- # Fallback: use first part of release name
- subtitle.movie_name = movie_release_name.split()[0] if movie_release_name else 'Unknown'
- # movie_name is already set correctly from the API response
-
- logger.debug('Found subtitle %r by %s via scraper', subtitle, matched_by)
+
+ subtitle.uploader = sub.get('uploader', 'anonymous')
+ # Store download_url for the download step
+ subtitle.scraper_download_url = download_url
+
+ logger.debug('Found scraper subtitle: %s [%s] by %s (%d downloads)',
+ filename, lang_2, subtitle.uploader, sub.get('download_count', 0))
subtitles.append(subtitle)
-
+
except (KeyError, ValueError, TypeError) as e:
- logger.warning('Failed to parse subtitle item from scraper: %s', e)
+ logger.warning('Failed to parse scraper subtitle: %s', e)
continue
+ logger.info('Scraper: returning %d subtitles after filtering', len(subtitles))
return subtitles
def _download_subtitle_scraper(self, subtitle):
- """
- Download subtitle content from scraper service.
-
- Args:
- subtitle: OpenSubtitlesSubtitle object
- """
- logger.info('Downloading subtitle %r via scraper', subtitle)
-
+ """Download subtitle content via the v1 API."""
+ logger.info('Downloading subtitle %d via scraper', subtitle.subtitle_id)
+
+ download_url = getattr(subtitle, 'scraper_download_url', None)
+ if not download_url:
+ download_url = f'https://www.opensubtitles.org/en/subtitles/{subtitle.subtitle_id}'
+
try:
- response = self._make_scraper_request('/download', {
- 'subtitle_id': str(subtitle.subtitle_id)
+ response = self._scraper_request('/api/v1/download/subtitle', {
+ 'subtitle_id': str(subtitle.subtitle_id),
+ 'download_url': download_url,
})
-
- if response and response.get('data'):
- # The scraper returns base64-encoded subtitle content
- subtitle.content = base64.b64decode(response['data'])
+
+ content = response.get('content')
+ if content:
+ subtitle.content = base64.b64decode(content)
else:
raise ServiceUnavailable('No subtitle content received from scraper')
-
+
except APIThrottled:
raise
- except requests.RequestException as e:
+ except Exception as e:
logger.error('Failed to download subtitle from scraper: %s', e)
raise ServiceUnavailable(f'Scraper download failed: {e}')
diff --git a/custom_libs/subliminal_patch/providers/opensubtitlescom.py b/custom_libs/subliminal_patch/providers/opensubtitlescom.py
index ccf5cbddbd..f07ecfb68b 100644
--- a/custom_libs/subliminal_patch/providers/opensubtitlescom.py
+++ b/custom_libs/subliminal_patch/providers/opensubtitlescom.py
@@ -348,9 +348,10 @@ def query(self, languages, video):
params.append(('episode_number', self.video.episode))
if self.video.season:
params.append(('season_number', self.video.season))
+
if self.video.series_imdb_id:
params.append(('parent_imdb_id', self.sanitize_external_ids(self.video.series_imdb_id)))
- if title_id:
+ elif title_id:
params.append(('parent_feature_id', title_id))
else:
if not imdb_id and not title_id:
diff --git a/custom_libs/subliminal_patch/providers/subssabbz.py b/custom_libs/subliminal_patch/providers/subssabbz.py
index 63120fc116..5485d75396 100644
--- a/custom_libs/subliminal_patch/providers/subssabbz.py
+++ b/custom_libs/subliminal_patch/providers/subssabbz.py
@@ -6,12 +6,13 @@
import os
import codecs
import unicodedata
+import time
from hashlib import sha1
from random import randint
from bs4 import BeautifulSoup
from zipfile import ZipFile, is_zipfile
from rarfile import RarFile, is_rarfile
-from requests import Session
+from requests import Session, HTTPError
from guessit import guessit
from dogpile.cache.api import NO_VALUE
from subliminal_patch.providers import Provider
@@ -51,6 +52,27 @@ def fix_movie_naming(title):
}, True)
+def _retry_on_403(method):
+ def retry(self, *args, **kwargs):
+ retries = 0
+ while 3 > retries:
+ try:
+ return method(self, *args, **kwargs)
+ except HTTPError as error:
+ if error.response.status_code != 403:
+ raise
+
+ retries += 1
+
+ logger.debug("403 returned. Retrying in 10 seconds")
+ time.sleep(10)
+
+ logger.debug("Retries limit exceeded. Ignoring query")
+ return []
+
+ return retry
+
+
class SubsSabBzSubtitle(Subtitle):
"""SubsSabBz Subtitle."""
provider_name = 'subssabbz'
@@ -140,6 +162,7 @@ def initialize(self):
def terminate(self):
self.session.close()
+ @_retry_on_403
def query(self, language, video):
subtitles = []
isEpisode = isinstance(video, Episode)
diff --git a/custom_libs/subliminal_patch/providers/subx.py b/custom_libs/subliminal_patch/providers/subx.py
index 2ed3b2308a..a46a71e4be 100644
--- a/custom_libs/subliminal_patch/providers/subx.py
+++ b/custom_libs/subliminal_patch/providers/subx.py
@@ -256,9 +256,9 @@ def run_query(self, query, video, video_type, season=None, episode=None):
return []
elif response.status_code == 429:
- # Rate limited - exponential backoff
+ # Rate limited - use Retry-After header if available
if attempt < max_retries - 1:
- wait_time = 2 ** attempt
+ wait_time = int(response.headers.get("Retry-After", 60 * (attempt + 1)))
logger.warning("Rate limit hit, waiting %ds before retry %d/%d",
wait_time, attempt + 1, max_retries)
time.sleep(wait_time)
@@ -282,6 +282,33 @@ def run_query(self, query, video, video_type, season=None, episode=None):
# Success
response.raise_for_status()
data = response.json()
+
+ # Proactively slow down if approaching rate limit
+ remaining = response.headers.get("X-RateLimit-Remaining")
+ limit = response.headers.get("X-RateLimit-Limit")
+ reset = response.headers.get("X-RateLimit-Reset")
+
+ if remaining is not None and limit is not None:
+ try:
+ remaining_int = int(remaining)
+ limit_int = int(limit)
+
+ # Slow down when below 20% of quota
+ if remaining_int < limit_int * 0.2:
+ if reset is not None:
+ # Wait exactly until the window resets
+ wait_time = max(0, int(reset) - int(time.time()))
+ else:
+ wait_time = 2 # Fallback
+
+ logger.warning(
+ "Approaching SubX rate limit (%d/%d remaining), waiting %ds",
+ remaining_int, limit_int, wait_time
+ )
+ time.sleep(wait_time)
+ except ValueError:
+ pass
+
break # Exit retry loop
except Exception as e:
@@ -343,8 +370,8 @@ def run_query(self, query, video, video_type, season=None, episode=None):
if not page_url and item.get("id"):
page_url = f"{_SUBX_BASE_URL}/api/subtitles/{item['id']}"
- # Detect language variant (Spain vs LATAM) from description
- description = item.get("description", "")
+ # Detect language variant (Spain vs LatAm) from description
+ description = item.get("description") or ""
spain = _SPANISH_RE.search(description.lower()) is not None
language = Language.fromalpha2("es") if spain else Language("spa", "MX")
@@ -369,7 +396,7 @@ def run_query(self, query, video, video_type, season=None, episode=None):
page_url = f"{_SUBX_BASE_URL}/api/subtitles/{item['id']}"
# Detect language variant from description
- description = item.get("description", "")
+ description = item.get("description") or ""
spain = _SPANISH_RE.search(description.lower()) is not None
language = Language.fromalpha2("es") if spain else Language("spa", "MX")
diff --git a/custom_libs/subliminal_patch/score.py b/custom_libs/subliminal_patch/score.py
index 2bcbd9776d..b0810b34fd 100644
--- a/custom_libs/subliminal_patch/score.py
+++ b/custom_libs/subliminal_patch/score.py
@@ -31,33 +31,38 @@ def framerate_equal(source, check):
DEFAULT_SCORES = {
"episode": {
"hash": 359,
- "series": 180,
+ "series": 160,
"year": 90,
"season": 30,
"episode": 30,
- "release_group": 14,
- "source": 7,
- "audio_codec": 3,
- "resolution": 2,
- "video_codec": 2,
- "streaming_service": 1,
+ "source": 25,
+ "release_group": 20,
+ "audio_codec": 1,
+ "resolution": 1,
+ "video_codec": 1,
"hearing_impaired": 1,
+ "streaming_service": 1,
},
"movie": {
- "hash": 119,
+ "hash": 179,
"title": 60,
- "year": 30,
- "release_group": 13,
- "source": 7,
- "audio_codec": 3,
- "resolution": 2,
- "video_codec": 2,
- "streaming_service": 1,
- "edition": 1,
+ "year": 40,
+ "source": 30,
+ "edition": 30,
+ "release_group": 15,
+ "audio_codec": 1,
+ "resolution": 1,
+ "video_codec": 1,
"hearing_impaired": 1,
+ "streaming_service": 1,
},
}
+MAX_SCORES = {
+ "episode": sum(v for k, v in DEFAULT_SCORES["episode"].items() if k != "hash"),
+ "movie": sum(v for k, v in DEFAULT_SCORES["movie"].items() if k != "hash"),
+}
+
def _check_hash_sum(scores: dict):
hash_val = scores["hash"]
diff --git a/custom_libs/subzero/analytics.py b/custom_libs/subzero/analytics.py
deleted file mode 100644
index bda9d62521..0000000000
--- a/custom_libs/subzero/analytics.py
+++ /dev/null
@@ -1,34 +0,0 @@
-# coding=utf-8
-
-from __future__ import absolute_import
-import struct
-import binascii
-
-from pyga.requests import Event, Page, Tracker, Session, Visitor, Config
-
-
-def track_event(category=None, action=None, label=None, value=None, identifier=None, first_use=None, add=None,
- noninteraction=True):
- anonymousConfig = Config()
- anonymousConfig.anonimize_ip_address = True
-
- tracker = Tracker('UA-86466078-1', 'none', conf=anonymousConfig)
- visitor = Visitor()
-
- # convert the last 8 bytes of the machine identifier to an integer to get a "unique" user
- visitor.unique_id = struct.unpack("!I", binascii.unhexlify(identifier[32:]))[0]/2
-
- if add:
- # add visitor's ip address (will be anonymized)
- visitor.ip_address = add
-
- if first_use:
- visitor.first_visit_time = first_use
-
- session = Session()
- event = Event(category=category, action=action, label=label, value=value, noninteraction=noninteraction)
- path = u"/" + u"/".join([category, action, label])
- page = Page(path.lower())
-
- tracker.track_event(event, session, visitor)
- tracker.track_pageview(page, session, visitor)
diff --git a/custom_libs/subzero/modification/__init__.py b/custom_libs/subzero/modification/__init__.py
index 73d75dea9a..4786d344ed 100644
--- a/custom_libs/subzero/modification/__init__.py
+++ b/custom_libs/subzero/modification/__init__.py
@@ -2,5 +2,5 @@
from __future__ import absolute_import
from .registry import registry
-from .mods import hearing_impaired, ocr_fixes, fps, offset, common, color, emoji
+from .mods import hearing_impaired, ocr_fixes, fps, offset, common, color, emoji, two_point_fit
from .main import SubtitleModifications, SubMod
diff --git a/custom_libs/subzero/modification/mods/two_point_fit.py b/custom_libs/subzero/modification/mods/two_point_fit.py
new file mode 100644
index 0000000000..8c8e732f8a
--- /dev/null
+++ b/custom_libs/subzero/modification/mods/two_point_fit.py
@@ -0,0 +1,29 @@
+# coding=utf-8
+
+from __future__ import absolute_import
+import logging
+
+from subzero.modification.mods import SubtitleModification
+from subzero.modification import registry
+
+logger = logging.getLogger(__name__)
+
+
+class TwoPointFit(SubtitleModification):
+ identifier = "two_point_fit"
+ description = "Use first and last sentences to linearly align timing of the subtitles"
+ exclusive = False
+ advanced = True
+ modifies_whole_file = True
+
+ def modify(self, content, debug=False, parent=None, **kwargs):
+
+ """
+ Place first sentence at 00:00:00 and scale until duration matches, then offset back
+ """
+
+ parent.f.shift(h=-int(kwargs.get("rh", 0)), m=-int(kwargs.get("rm", 0)), s=-int(kwargs.get("rs", 0)), ms=-int(kwargs.get("rms", 0)))
+ parent.f.transform_framerate(float(kwargs.get("from")), float(kwargs.get("to")))
+ parent.f.shift(h=int(kwargs.get("oh", 0)), m=int(kwargs.get("om", 0)), s=int(kwargs.get("os", 0)), ms=int(kwargs.get("oms", 0)))
+
+registry.register(TwoPointFit)
diff --git a/docker-compose.yml b/docker-compose.yml
index 4aced79eff..e5fa84c580 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,7 +1,7 @@
# =============================================================================
-# Bazarr LavX Fork - Docker Compose
+# Bazarr+ Docker Compose
# =============================================================================
-# Production deployment configuration with OpenSubtitles.org Scraper
+# Production deployment configuration
#
# Usage:
# docker compose up -d
diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh
index 29df93ea55..170fa7fa01 100644
--- a/docker/entrypoint.sh
+++ b/docker/entrypoint.sh
@@ -14,14 +14,19 @@ set -e
PUID=${PUID:-1000}
PGID=${PGID:-1000}
-echo "
-โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-โ Bazarr (LavX Fork) Docker Container โ
-โ โ
-โ OpenSubtitles.org scraper support included โ
-โ https://github.com/LavX/bazarr โ
-โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-"
+echo '
+__/\\\\\\\\\\\\\_________________________________________________________________________________________
+ _\/\\\/////////\\\_______________________________________________________________________________________
+ _\/\\\_______\/\\\______________________________________________________________________________/\\\_____
+ _\/\\\\\\\\\\\\\\___/\\\\\\\\\_____/\\\\\\\\\\\__/\\\\\\\\\_____/\\/\\\\\\\___/\\/\\\\\\\______\/\\\_____
+ _\/\\\/////////\\\_\////////\\\___\///////\\\/__\////////\\\___\/\\\/////\\\_\/\\\/////\\\__/\\\\\\\\\\\_
+ _\/\\\_______\/\\\___/\\\\\\\\\\_______/\\\/______/\\\\\\\\\\__\/\\\___\///__\/\\\___\///__\/////\\\///__
+ _\/\\\_______\/\\\__/\\\/////\\\_____/\\\/_______/\\\/////\\\__\/\\\_________\/\\\_____________\/\\\_____
+ _\/\\\\\\\\\\\\\/__\//\\\\\\\\/\\__/\\\\\\\\\\\_\//\\\\\\\\/\\_\/\\\_________\/\\\_____________\///_____
+ _\/////////////_____\////////\//__\///////////___\////////\//__\///__________\///________________________
+
+Repository: https://github.com/LavX/bazarr
+'
echo "Starting with UID: $PUID, GID: $PGID"
diff --git a/docs/AI_TRANSLATOR_INVESTIGATION.md b/docs/AI_TRANSLATOR_INVESTIGATION.md
deleted file mode 100644
index ab2ede82aa..0000000000
--- a/docs/AI_TRANSLATOR_INVESTIGATION.md
+++ /dev/null
@@ -1,248 +0,0 @@
-# AI Subtitle Translator Integration - Investigation Report
-
-## Executive Summary
-
-This document details the findings from investigating two issues with the AI Subtitle Translator integration in Bazarr:
-1. Configuration not being applied to the microservice
-2. Understanding how AI translation gets triggered
-
----
-
-## Issue 1: Configuration Not Being Applied to Microservice
-
-### Symptoms
-- User enters API key and Max Concurrent Jobs in Settings
-- Service Status panel shows "API Key: ร Not Set" and "Max Concurrent: 2" (default)
-
-### Root Cause Analysis
-
-The architecture involves **two separate systems**:
-
-```
-โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-โ BAZARR โ โ AI SUBTITLE TRANSLATOR โ
-โ โ โ (Microservice) โ
-โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ โ โ
-โ โ Frontend Settings Page โ โ โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
-โ โ - API Key input โ โ โ โ /api/v1/status โ โ
-โ โ - Max Concurrent selector โโโโโโผโโโโโโ โ Returns microservice's โ โ
-โ โ - Model selector โ โ โ โ internal config (NOT โ โ
-โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ โ โ Bazarr's config) โ โ
-โ โ โ โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
-โ โผ โ โ โ
-โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
-โ โ Bazarr Config (config.yaml)โ โ โ โ /api/v1/jobs/translate โ โ
-โ โ - openrouter_api_key โ โ โ โ Receives config PER- โ โ
-โ โ - openrouter_max_concurrentโ โ โ โ REQUEST in payload โ โ
-โ โ - openrouter_model โ โ โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
-โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ โ โ
-โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-```
-
-#### Data Flow When Saving Settings:
-1. User enters values in frontend Settings page
-2. Frontend POSTs to `/api/system/settings`
-3. Bazarr saves to its config file (`config.yaml`)
-4. **Config is NOT sent to microservice** - settings are saved locally only
-
-#### Data Flow When Translating:
-1. Translation request is submitted
-2. [`OpenRouterTranslatorService._submit_and_poll()`](bazarr/subtitles/tools/translate/services/openrouter_translator.py:110) creates payload WITH config:
-```python
-payload = {
- ...
- "config": {
- "apiKey": settings.translator.openrouter_api_key,
- "model": settings.translator.openrouter_model,
- "temperature": settings.translator.openrouter_temperature,
- }
-}
-```
-3. **Problem**: `maxConcurrent` is NOT included in the payload!
-
-#### Data Flow When Viewing Status Panel:
-1. [`TranslatorStatusPanel`](frontend/src/components/TranslatorStatus.tsx:146) calls `useTranslatorStatus()`
-2. Fetches from Bazarr API `/translator/status`
-3. [`TranslatorStatus.get()`](bazarr/api/translator/translator.py:29) proxies to microservice `/api/v1/status`
-4. **Returns microservice's internal config** (defaults), not Bazarr's saved config
-
-### The Design Issue
-
-The current design sends config **per-request** (at translation time), but:
-1. **`max_concurrent` is missing** from the translation payload
-2. **Status panel shows wrong data** - displays microservice defaults, not Bazarr's settings
-
-### Solution
-
-#### Fix 1: Add `maxConcurrent` to translation payload
-
-In [`bazarr/subtitles/tools/translate/services/openrouter_translator.py`](bazarr/subtitles/tools/translate/services/openrouter_translator.py:137), add `maxConcurrent`:
-
-```python
-# Line 136-142
-payload = {
- ...
- "config": {
- "apiKey": settings.translator.openrouter_api_key,
- "model": settings.translator.openrouter_model,
- "temperature": settings.translator.openrouter_temperature,
- "maxConcurrent": settings.translator.openrouter_max_concurrent, # ADD THIS
- }
-}
-```
-
-#### Fix 2: Update Status Panel to show Bazarr's config
-
-Option A: **Modify Status Panel to show Bazarr settings** (Recommended)
-- Update frontend to fetch Bazarr's settings from `/api/system/settings`
-- Display Bazarr's config alongside microservice status
-
-Option B: **Add endpoint to sync config to microservice**
-- Add a POST method to [`TranslatorConfig`](bazarr/api/translator/translator.py:128)
-- Call it when settings are saved
-- Microservice would need a `/api/v1/config` POST endpoint
-
----
-
-## Issue 2: How AI Translation Gets Triggered
-
-### Current Behavior: Manual Only
-
-AI translation is **NOT automatic**. It must be manually triggered by the user.
-
-### Translation Trigger Flow
-
-```
-โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-โ MANUAL TRANSLATION FLOW โ
-โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-
-User Action in Bazarr UI:
-โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-โ Episode/Movie Detail Page โ
-โ Click on subtitle file โ
-โ Select "Translate" action โ
-โ Choose target language โ
-โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
- โ
- โผ
-โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-โ Frontend API Call โ
-โ PATCH /api/subtitles/subtitles โ
-โ action: "translate" โ
-โ language: "target_lang" โ
-โ path: "/path/to/subtitle.srt" โ
-โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
- โ
- โผ
-โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-โ bazarr/api/subtitles/ โ
-โ subtitles.py:170-181 โ
-โ if action == 'translate': โ
-โ translate_subtitles_file() โ
-โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
- โ
- โผ
-โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-โ bazarr/subtitles/tools/ โ
-โ translate/main.py:12 โ
-โ translate_subtitles_file() โ
-โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
- โ
- โผ
-โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-โ TranslatorFactory โ
-โ .create_translator() โ
-โ Based on translator_type: โ
-โ - google_translate โ
-โ - gemini โ
-โ - lingarr โ
-โ - openrouter (AI Translator) โ
-โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
- โ
- โผ (if openrouter)
-โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-โ OpenRouterTranslatorService โ
-โ 1. Load subtitle file โ
-โ 2. Submit job to microservice โ
-โ 3. Poll for completion โ
-โ 4. Save translated subtitle โ
-โ 5. Log to history โ
-โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-```
-
-### How to Trigger Translation
-
-#### Method 1: Via Bazarr UI (Recommended)
-1. Go to Series/Movies in Bazarr
-2. Click on an episode/movie
-3. Look at the subtitles section
-4. Click on an existing subtitle file (e.g., English)
-5. Select "Translate" from the actions menu
-6. Choose the target language
-7. Click confirm
-
-#### Method 2: Via API
-```bash
-curl -X PATCH "http://localhost:6767/api/subtitles/subtitles" \
- -H "X-API-KEY: your-api-key" \
- -F "action=translate" \
- -F "language=hu" \
- -F "path=/path/to/subtitle.en.srt" \
- -F "type=episode" \
- -F "id=12345"
-```
-
-### Automatic Translation (NOT Currently Implemented)
-
-There is currently **no automatic translation** in Bazarr. To implement this, you would need to:
-
-1. **Post-download hook**: Add translation to the subtitle download pipeline
-2. **Scheduled task**: Create a scheduler job to translate missing languages
-3. **Language profile enhancement**: Add "auto-translate" option to language profiles
-
----
-
-## Relevant Files Reference
-
-### Configuration
-- [`bazarr/app/config.py:186-197`](bazarr/app/config.py:186) - Translator settings validators
-
-### Backend API
-- [`bazarr/api/translator/translator.py`](bazarr/api/translator/translator.py) - Translator API endpoints (status, jobs, config)
-- [`bazarr/api/subtitles/subtitles.py:170-181`](bazarr/api/subtitles/subtitles.py:170) - Translate action handler
-- [`bazarr/api/system/settings.py`](bazarr/api/system/settings.py) - Settings save endpoint
-
-### Translation Services
-- [`bazarr/subtitles/tools/translate/main.py`](bazarr/subtitles/tools/translate/main.py) - Main translation entry point
-- [`bazarr/subtitles/tools/translate/services/translator_factory.py`](bazarr/subtitles/tools/translate/services/translator_factory.py) - Translator factory
-- [`bazarr/subtitles/tools/translate/services/openrouter_translator.py`](bazarr/subtitles/tools/translate/services/openrouter_translator.py) - AI Subtitle Translator service
-
-### Frontend
-- [`frontend/src/pages/Settings/Subtitles/index.tsx:534-655`](frontend/src/pages/Settings/Subtitles/index.tsx:534) - Translator settings UI
-- [`frontend/src/components/TranslatorStatus.tsx`](frontend/src/components/TranslatorStatus.tsx) - Status panel component
-- [`frontend/src/apis/hooks/translator.ts`](frontend/src/apis/hooks/translator.ts) - API hooks for translator
-
----
-
-## Recommended Fixes Summary
-
-### Immediate Fixes
-
-1. **Add `maxConcurrent` to translation payload** (1 line change)
- - File: `bazarr/subtitles/tools/translate/services/openrouter_translator.py`
- - Add: `"maxConcurrent": settings.translator.openrouter_max_concurrent`
-
-2. **Update Status Panel display**
- - Show Bazarr's configured values, with clarification label
- - Alternative: Sync config to microservice on save
-
-### Future Enhancements
-
-1. **Add automatic translation option**
- - Post-download hook for newly downloaded subtitles
- - Option in language profiles
-
-2. **Real-time config sync**
- - Push config changes to microservice immediately
- - Keep microservice in sync with Bazarr settings
\ No newline at end of file
diff --git a/docs/FORK_MAINTENANCE.md b/docs/FORK_MAINTENANCE.md
index f9aba4cdfc..3f22a05033 100644
--- a/docs/FORK_MAINTENANCE.md
+++ b/docs/FORK_MAINTENANCE.md
@@ -1,285 +1,88 @@
-# Bazarr LavX Fork - Maintenance Guide
+# Bazarr+ Maintenance Guide
-This document describes the automated workflow for maintaining this fork of [Bazarr](https://github.com/morpheus65535/bazarr).
+Bazarr+ is a hard fork of [Bazarr](https://github.com/morpheus65535/bazarr). It shares the original codebase as a starting point but is developed independently. There is no automatic upstream synchronization.
-## Overview
+## Relationship with Upstream
-This fork contains custom modifications (primarily the OpenSubtitles.org web scraper provider) that are automatically kept in sync with the upstream Bazarr repository. The workflow handles:
+Bazarr+ may selectively cherry-pick bug fixes from upstream when relevant, but does not merge upstream releases wholesale. The codebases have diverged significantly in security model, UI, features, and architecture.
-1. **Upstream Synchronization** - Daily automatic merging of upstream changes
-2. **Conflict Resolution** - Automatic PR creation when merge conflicts occur
-3. **Docker Builds** - Automated multi-architecture Docker image builds
-4. **Publishing** - Images published to GitHub Container Registry
+**When cherry-picking from upstream:**
+1. Evaluate whether the fix applies to Bazarr+ (upstream may fix things we've already addressed differently)
+2. Cherry-pick individual commits: `git cherry-pick `
+3. Review for conflicts with fork-specific code (security hardening, telemetry removal, UI changes)
+4. Test thoroughly before merging
-## Architecture
-
-```
-โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-โ GitHub Actions Workflows โ
-โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
-โ โ
-โ โโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
-โ โ sync-upstream.yml โ โ build-docker.yml โ โ
-โ โ โ โ โ โ
-โ โ โข Daily 4 AM UTC โโโโโโโโโ>โ โข Build frontend โ โ
-โ โ โข Fetch upstream โ trigger โ โข Build Docker image โ โ
-โ โ โข Auto merge โ โ โข Push to ghcr.io โ โ
-โ โ โข Conflict PR โ โ โข multi-arch (amd64/arm64) โ โ
-โ โโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
-โ โ
-โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-```
-
-## Workflows
-
-### 1. Upstream Sync (`sync-upstream.yml`)
-
-**Schedule:** Daily at 4:00 AM UTC (5:00 AM Budapest time)
-
-**Triggers:**
-- Scheduled cron job
-- Manual dispatch from GitHub Actions UI
+## Versioning
-**Process:**
-1. Fetches latest commits from `morpheus65535/bazarr:master`
-2. Compares with current fork
-3. Attempts automatic merge
-4. If successful: pushes changes and triggers Docker build
-5. If conflicts: creates a PR with `sync-conflict` label
+Bazarr+ uses independent semantic versioning, starting at v2.0.0. Versions are not tied to upstream Bazarr release numbers.
-**Manual Trigger:**
-```bash
-# Via GitHub CLI
-gh workflow run sync-upstream.yml
+```
+v{major}.{minor}.{patch}
-# With force sync (even if no new commits)
-gh workflow run sync-upstream.yml -f force_sync=true
+Example: v2.0.0, v2.1.0, v2.0.1
```
-### 2. Docker Build (`build-docker.yml`)
+- Patch (v2.0.1): bug fixes
+- Minor (v2.1.0): new features, backwards-compatible
+- Major (v3.0.0): breaking changes
+
+## Docker Build (`build-docker.yml`)
**Triggers:**
-- Push to `main` branch
-- Called by sync workflow after successful merge
+- Push to `master` branch
- Manual dispatch
- Tag creation (for releases)
**Output:**
- `ghcr.io/lavx/bazarr:latest` - Latest build
-- `ghcr.io/lavx/bazarr:vX.Y.Z-lavx.YYYYMMDD` - Versioned build
+- `ghcr.io/lavx/bazarr:X.Y.Z` - Versioned build
- `ghcr.io/lavx/bazarr:sha-XXXXXXX` - Git SHA reference
-## Using the Docker Image
+## Branch Model
-### Quick Start
-
-```bash
-docker run -d \
- --name bazarr \
- -p 6767:6767 \
- -v /path/to/config:/config \
- -v /path/to/movies:/movies \
- -v /path/to/tv:/tv \
- -e PUID=1000 \
- -e PGID=1000 \
- -e TZ=Europe/Budapest \
- ghcr.io/lavx/bazarr:latest
-```
-
-### Docker Compose
-
-Create a `docker-compose.yml`:
-
-```yaml
-services:
- bazarr:
- image: ghcr.io/lavx/bazarr:latest
- container_name: bazarr
- restart: unless-stopped
- ports:
- - "6767:6767"
- environment:
- - PUID=1000
- - PGID=1000
- - TZ=Europe/Budapest
- volumes:
- - ./config:/config
- - /path/to/movies:/movies
- - /path/to/tv:/tv
-```
-
-Then run:
-```bash
-docker compose up -d
-```
-
-### Environment Variables
-
-| Variable | Default | Description |
-|----------|---------|-------------|
-| `PUID` | `1000` | User ID for file permissions |
-| `PGID` | `1000` | Group ID for file permissions |
-| `TZ` | `UTC` | Timezone (e.g., `Europe/Budapest`) |
-
-## Handling Merge Conflicts
-
-When upstream changes conflict with fork modifications:
-
-1. **Notification:** A PR is automatically created with the `sync-conflict` label
-2. **Review:** Check the PR for conflict markers
-3. **Fix locally:**
- ```bash
- git fetch origin sync/upstream-XXXXXXXX
- git checkout sync/upstream-XXXXXXXX
- # Resolve conflicts in your editor
- git add .
- git commit -m "Resolve merge conflicts"
- git push origin sync/upstream-XXXXXXXX
- ```
-4. **Merge:** Merge the PR via GitHub UI
-5. **Build:** Docker build will trigger automatically
+- `master` contains stable releases
+- `development` is the integration branch where new features land
+- Feature branches are created from `development` and merged back via PR
## Fork-Specific Files
-These files are unique to this fork and should be preserved during merges:
+Key files that define Bazarr+ and differentiate it from upstream:
| File | Purpose |
|------|---------|
| `custom_libs/subliminal_patch/providers/opensubtitles_scraper.py` | OpenSubtitles.org scraper mixin |
| `custom_libs/subliminal_patch/providers/opensubtitles.py` | Modified provider with scraper support |
-| `opensubtitles-scraper/` | Git submodule - web scraper service |
+| `opensubtitles-scraper/` | Git submodule: web scraper service |
+| `ai-subtitle-translator/` | Git submodule: AI translator service |
| `package_info` | Fork identification (shown in System Status) |
-| `bazarr/app/check_update.py` | Modified to use fork's releases |
-| `.github/workflows/sync-upstream.yml` | Upstream sync workflow |
+| `bazarr/app/check_update.py` | Uses fork's releases, not upstream |
| `.github/workflows/build-docker.yml` | Docker build workflow |
-| `Dockerfile` | Production Docker image |
+| `Dockerfile` | Production Docker image (Python 3.14) |
| `docker-compose.yml` | User deployment template |
-| `.gitattributes` | Merge conflict protection rules |
-
-## OpenSubtitles Scraper Service
+| `bazarr/utilities/analytics.py` | Deleted (contained GA4 + UA telemetry) |
-This fork includes the [OpenSubtitles Scraper](https://github.com/LavX/opensubtitles-scraper) as a git submodule. The scraper is a standalone service that provides web scraping capabilities for OpenSubtitles.org.
-
-### Architecture
-
-```
-โโโโโโโโโโโโโโโโโโโโโโ HTTP API โโโโโโโโโโโโโโโโโโโโโโโโโโโ
-โ Bazarr โ โโโโโโโโโโโโโโโ> โ OpenSubtitles Scraper โ
-โ (LavX Fork) โ โ (Port 8765) โ
-โ โ <โโโโโโโโโโโโโโโ โ โ
-โ Uses provider: โ JSON Response โ Scrapes: โ
-โ opensubtitles.org โ โ - opensubtitles.org โ
-โโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโโโโ
-```
-
-### Docker Compose Deployment
-
-The `docker-compose.yml` includes both services:
-
-```yaml
-services:
- opensubtitles-scraper:
- image: ghcr.io/lavx/opensubtitles-scraper:latest
- ports:
- - "8765:8765"
-
- bazarr:
- image: ghcr.io/lavx/bazarr:latest
- depends_on:
- - opensubtitles-scraper
- environment:
- - OPENSUBTITLES_SCRAPER_URL=http://opensubtitles-scraper:8765
-```
+## Auto-Update Behavior
-### Updating the Scraper Submodule
+### Docker: Auto-Update is Disabled
-To update the scraper to the latest version:
+The Docker image runs with `--no-update` flag. Update by pulling new images:
```bash
-cd opensubtitles-scraper
-git pull origin main
-cd ..
-git add opensubtitles-scraper
-git commit -m "Update opensubtitles-scraper submodule"
-git push
-```
-
-## Versioning
-
-This fork uses a versioning scheme that combines upstream version with fork identifier:
-
-```
-{upstream_version}-lavx.{date}
-
-Example: v1.5.3-lavx.20241214
+docker compose pull
+docker compose up -d
```
-This makes it clear:
-- Which upstream version the build is based on
-- When the fork build was created
-- That it contains fork-specific modifications
+### Release Repository
-## Auto-Update Behavior
-
-### Important: Auto-Update is Disabled in Docker
-
-The Docker image runs with `--no-update` flag to prevent Bazarr's built-in update mechanism from overwriting your fork modifications. **This is intentional.**
-
-### How Updates Work
-
-| Scenario | Behavior |
-|----------|----------|
-| **Docker Container** | Auto-update disabled; use new Docker image for updates |
-| **Manual Installation** | Auto-update can be enabled, but will pull from this fork |
-| **Release Info in UI** | Shows releases from this fork (LavX/bazarr) |
-
-### Release Repository Configuration
-
-The fork is configured to check releases from `LavX/bazarr` instead of upstream. This is controlled by:
+The fork checks releases from `LavX/bazarr`:
```python
# In bazarr/app/check_update.py
RELEASES_REPO = os.environ.get('BAZARR_RELEASES_REPO', 'LavX/bazarr')
```
-To change the release source (e.g., for debugging), set the environment variable:
-
-```yaml
-# docker-compose.yml
-environment:
- - BAZARR_RELEASES_REPO=morpheus65535/Bazarr # Use upstream releases
-```
-
-### Updating Docker Containers
-
-To update to a new version:
-
-```bash
-# Pull the latest image
-docker compose pull
-
-# Recreate the container
-docker compose up -d
-
-# Or in one command
-docker compose up -d --pull always
-```
-
-### Why Docker Doesn't Auto-Update
-
-1. **Preservation of modifications**: Auto-update would download vanilla Bazarr, losing the OpenSubtitles scraper
-2. **Immutable containers**: Docker best practices recommend replacing containers rather than modifying them
-3. **Reproducibility**: Pinned versions ensure consistent behavior
-4. **Rollback capability**: Easy to rollback by pulling a specific tag
-
## Troubleshooting
-### Sync Workflow Fails
-
-1. Check the workflow logs in GitHub Actions
-2. Verify upstream repository is accessible
-3. Check if there are unresolved conflicts from previous sync
-
### Docker Build Fails
1. Check if frontend build succeeded
@@ -296,17 +99,8 @@ echo $GITHUB_TOKEN | docker login ghcr.io -u USERNAME --password-stdin
docker pull ghcr.io/lavx/bazarr:latest
```
-## Contributing
-
-When making changes to fork-specific files:
-
-1. Test changes locally first
-2. Ensure changes don't conflict with upstream structure
-3. Document any new environment variables or features
-4. Update this documentation if workflow changes
-
## Related Links
-- [Upstream Bazarr Repository](https://github.com/morpheus65535/bazarr)
+- [Upstream Bazarr Repository](https://github.com/morpheus65535/bazarr) (original, not synced)
- [GitHub Container Registry](https://ghcr.io/lavx/bazarr)
-- [Bazarr Wiki](https://wiki.bazarr.media)
\ No newline at end of file
+- [Contributing Guide](../CONTRIBUTING.md)
diff --git a/frontend/.eslintrc.json b/frontend/.eslintrc.json
deleted file mode 100644
index f1418bfdf5..0000000000
--- a/frontend/.eslintrc.json
+++ /dev/null
@@ -1,70 +0,0 @@
-{
- "rules": {
- "no-console": "error",
- "camelcase": "warn",
- "no-restricted-imports": [
- "error",
- {
- "patterns": ["..*"]
- }
- ],
- "simple-import-sort/imports": "error",
- "@typescript-eslint/explicit-module-boundary-types": "off",
- "@typescript-eslint/no-empty-function": "warn",
- "@typescript-eslint/no-empty-interface": "off",
- "@typescript-eslint/no-unused-vars": "warn"
- },
- "extends": [
- "eslint:recommended",
- "plugin:react-hooks/recommended",
- "plugin:@typescript-eslint/recommended"
- ],
- "plugins": ["testing-library", "simple-import-sort", "react-refresh"],
- "overrides": [
- {
- "files": [
- "**/__tests__/**/*.[jt]s?(x)",
- "**/?(*.)+(spec|test).[jt]s?(x)"
- ],
- "extends": ["plugin:testing-library/react"]
- },
- {
- "files": ["*.ts", "*.tsx"],
- "rules": {
- "simple-import-sort/imports": [
- "error",
- {
- "groups": [
- [
- // React Packages
- "^react",
- // Mantine Packages
- "^@mantine/",
- // Vendor Packages
- "^(\\w|@\\w)",
- // Side Effect Imports
- "^\\u0000",
- // Internal Packages
- "^@/\\w",
- // Parent Imports
- "^\\.\\.(?!/?$)",
- "^\\.\\./?$",
- // Relative Imports
- "^\\./(?=.*/)(?!/?$)",
- "^\\.(?!/?$)",
- "^\\./?$",
- // Style Imports
- "^.+\\.?(css)$"
- ]
- ]
- }
- ]
- }
- }
- ],
- "parser": "@typescript-eslint/parser",
- "parserOptions": {
- "sourceType": "module",
- "ecmaVersion": "latest"
- }
-}
diff --git a/frontend/.husky/pre-commit b/frontend/.husky/pre-commit
index 71a781c99a..b47088403d 100755
--- a/frontend/.husky/pre-commit
+++ b/frontend/.husky/pre-commit
@@ -1,6 +1,3 @@
-#!/bin/sh
-. "$(dirname "$0")/_/husky.sh"
-
[ -n "$CI" ] && exit 0
cd frontend || exit
diff --git a/frontend/README.md b/frontend/README.md
index 5fcd3d4816..8539c40437 100644
--- a/frontend/README.md
+++ b/frontend/README.md
@@ -14,7 +14,7 @@
1. Clone or download this repository
```
- $ git clone https://github.com/morpheus65535/bazarr.git
+ $ git clone https://github.com/LavX/bazarr.git
$ cd bazarr/frontend
```
diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs
new file mode 100644
index 0000000000..136e64e304
--- /dev/null
+++ b/frontend/eslint.config.mjs
@@ -0,0 +1,89 @@
+import js from "@eslint/js";
+import tseslint from "typescript-eslint";
+import reactHooks from "eslint-plugin-react-hooks";
+import reactRefresh from "eslint-plugin-react-refresh";
+import simpleImportSort from "eslint-plugin-simple-import-sort";
+import testingLibrary from "eslint-plugin-testing-library";
+import globals from "globals";
+
+export default tseslint.config(
+ {
+ ignores: [
+ "dist",
+ "build",
+ "coverage",
+ "node_modules",
+ "dev-dist",
+ ".husky",
+ "eslint.config.mjs",
+ "vite.config.ts",
+ ],
+ },
+ js.configs.recommended,
+ ...tseslint.configs.recommended,
+ {
+ plugins: {
+ "react-hooks": reactHooks,
+ "react-refresh": reactRefresh.default || reactRefresh,
+ "simple-import-sort": simpleImportSort,
+ },
+ languageOptions: {
+ globals: {
+ ...globals.browser,
+ ...globals.node,
+ },
+ },
+ rules: {
+ ...reactHooks.configs.recommended.rules,
+ "no-console": "error",
+ camelcase: "warn",
+ "no-restricted-imports": [
+ "error",
+ {
+ patterns: ["..*"],
+ },
+ ],
+ "simple-import-sort/imports": "error",
+ "@typescript-eslint/explicit-module-boundary-types": "off",
+ "@typescript-eslint/no-empty-function": "warn",
+ "@typescript-eslint/no-empty-interface": "off",
+ "@typescript-eslint/no-unused-vars": "warn",
+ "@typescript-eslint/no-empty-object-type": "off",
+ "@typescript-eslint/no-unused-expressions": "off",
+ "no-useless-assignment": "off",
+ "react-hooks/set-state-in-effect": "off",
+ "react-hooks/refs": "off",
+ "react-hooks/static-components": "off",
+ "react-hooks/incompatible-library": "off",
+ },
+ },
+ {
+ files: ["**/*.ts", "**/*.tsx"],
+ rules: {
+ "simple-import-sort/imports": [
+ "error",
+ {
+ groups: [
+ [
+ "^react",
+ "^@mantine/",
+ "^(\\w|@\\w)",
+ "^\\u0000",
+ "^@/\\w",
+ "^\\.\\.(?!/?$)",
+ "^\\.\\./?$",
+ "^\\./(?=.*/)(?!/?$)",
+ "^\\.(?!/?$)",
+ "^\\./?$",
+ "^.+\\.?(css)$",
+ ],
+ ],
+ },
+ ],
+ },
+ },
+ {
+ files: ["**/__tests__/**/*.[jt]s?(x)", "**/?(*.)+(spec|test).[jt]s?(x)"],
+ ...testingLibrary.configs["flat/react"],
+ },
+);
diff --git a/frontend/index.html b/frontend/index.html
index 46f5375a87..0d79f37e5b 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -15,7 +15,7 @@
href="./images/maskable-icon-512x512.png"
color="#FFFFFF"
/>
-
+
=24.0.0"
}
},
"node_modules/@asamuzakjp/css-color/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==",
+ "version": "11.2.7",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz",
+ "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==",
"dev": true,
- "license": "ISC"
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/@asamuzakjp/dom-selector": {
+ "version": "6.8.1",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz",
+ "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@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.6"
+ }
+ },
+ "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": {
+ "version": "11.2.7",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz",
+ "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==",
+ "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==",
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
+ "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-validator-identifier": "^7.27.1",
+ "@babel/helper-validator-identifier": "^7.28.5",
"js-tokens": "^4.0.0",
"picocolors": "^1.1.1"
},
@@ -128,9 +178,9 @@
}
},
"node_modules/@babel/compat-data": {
- "version": "7.28.0",
- "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz",
- "integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==",
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz",
+ "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -168,21 +218,15 @@
"url": "https://opencollective.com/babel"
}
},
- "node_modules/@babel/core/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
- },
"node_modules/@babel/generator": {
- "version": "7.28.3",
- "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz",
- "integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==",
+ "version": "7.29.1",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
+ "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/parser": "^7.28.3",
- "@babel/types": "^7.28.2",
+ "@babel/parser": "^7.29.0",
+ "@babel/types": "^7.29.0",
"@jridgewell/gen-mapping": "^0.3.12",
"@jridgewell/trace-mapping": "^0.3.28",
"jsesc": "^3.0.2"
@@ -192,26 +236,26 @@
}
},
"node_modules/@babel/helper-annotate-as-pure": {
- "version": "7.25.9",
- "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.25.9.tgz",
- "integrity": "sha512-gv7320KBUFJz1RnylIg5WWYPRXKZ884AGkYpgpWW02TH66Dl+HaC1t1CKd0z3R4b6hdYEcmrNZHUmfCP+1u3/g==",
+ "version": "7.27.3",
+ "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz",
+ "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/types": "^7.25.9"
+ "@babel/types": "^7.27.3"
},
"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==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
+ "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/compat-data": "^7.27.2",
+ "@babel/compat-data": "^7.28.6",
"@babel/helper-validator-option": "^7.27.1",
"browserslist": "^4.24.0",
"lru-cache": "^5.1.1",
@@ -222,18 +266,18 @@
}
},
"node_modules/@babel/helper-create-class-features-plugin": {
- "version": "7.27.0",
- "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.27.0.tgz",
- "integrity": "sha512-vSGCvMecvFCd/BdpGlhpXYNhhC4ccxyvQWpbGL4CWbvfEoLFWUZuSuf7s9Aw70flgQF+6vptvgK2IfOnKlRmBg==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.6.tgz",
+ "integrity": "sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-annotate-as-pure": "^7.25.9",
- "@babel/helper-member-expression-to-functions": "^7.25.9",
- "@babel/helper-optimise-call-expression": "^7.25.9",
- "@babel/helper-replace-supers": "^7.26.5",
- "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9",
- "@babel/traverse": "^7.27.0",
+ "@babel/helper-annotate-as-pure": "^7.27.3",
+ "@babel/helper-member-expression-to-functions": "^7.28.5",
+ "@babel/helper-optimise-call-expression": "^7.27.1",
+ "@babel/helper-replace-supers": "^7.28.6",
+ "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1",
+ "@babel/traverse": "^7.28.6",
"semver": "^6.3.1"
},
"engines": {
@@ -244,14 +288,14 @@
}
},
"node_modules/@babel/helper-create-regexp-features-plugin": {
- "version": "7.27.0",
- "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.27.0.tgz",
- "integrity": "sha512-fO8l08T76v48BhpNRW/nQ0MxfnSdoSKUJBMjubOAYffsVuGG5qOfMq7N6Es7UJvi7Y8goXXo07EfcHZXDPuELQ==",
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.28.5.tgz",
+ "integrity": "sha512-N1EhvLtHzOvj7QQOUCCS3NrPJP8c5W6ZXCHDn7Yialuy1iu4r5EmIYkXlKNqT99Ciw+W0mDqWoR6HWMZlFP3hw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-annotate-as-pure": "^7.25.9",
- "regexpu-core": "^6.2.0",
+ "@babel/helper-annotate-as-pure": "^7.27.3",
+ "regexpu-core": "^6.3.1",
"semver": "^6.3.1"
},
"engines": {
@@ -262,22 +306,47 @@
}
},
"node_modules/@babel/helper-define-polyfill-provider": {
- "version": "0.6.4",
- "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.4.tgz",
- "integrity": "sha512-jljfR1rGnXXNWnmQg2K3+bvhkxB51Rl32QRaOTuwwjviGrHzIbSc8+x9CpraDtbT7mfyjXObULP4w/adunNwAw==",
+ "version": "0.6.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.7.tgz",
+ "integrity": "sha512-6Fqi8MtQ/PweQ9xvux65emkLQ83uB+qAVtfHkC9UodyHMIZdxNI01HjLCLUtybElp2KY2XNE0nOgyP1E1vXw9w==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-compilation-targets": "^7.22.6",
- "@babel/helper-plugin-utils": "^7.22.5",
- "debug": "^4.1.1",
+ "@babel/helper-compilation-targets": "^7.28.6",
+ "@babel/helper-plugin-utils": "^7.28.6",
+ "debug": "^4.4.3",
"lodash.debounce": "^4.0.8",
- "resolve": "^1.14.2"
+ "resolve": "^1.22.11"
},
"peerDependencies": {
"@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
}
},
+ "node_modules/@babel/helper-define-polyfill-provider/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@babel/helper-define-polyfill-provider/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==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@babel/helper-globals": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
@@ -289,43 +358,43 @@
}
},
"node_modules/@babel/helper-member-expression-to-functions": {
- "version": "7.25.9",
- "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.25.9.tgz",
- "integrity": "sha512-wbfdZ9w5vk0C0oyHqAJbc62+vet5prjj01jjJ8sKn3j9h3MQQlflEdXYvuqRWjHnM12coDEqiC1IRCi0U/EKwQ==",
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz",
+ "integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/traverse": "^7.25.9",
- "@babel/types": "^7.25.9"
+ "@babel/traverse": "^7.28.5",
+ "@babel/types": "^7.28.5"
},
"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==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
+ "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/traverse": "^7.27.1",
- "@babel/types": "^7.27.1"
+ "@babel/traverse": "^7.28.6",
+ "@babel/types": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-module-transforms": {
- "version": "7.28.3",
- "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz",
- "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
+ "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-module-imports": "^7.27.1",
- "@babel/helper-validator-identifier": "^7.27.1",
- "@babel/traverse": "^7.28.3"
+ "@babel/helper-module-imports": "^7.28.6",
+ "@babel/helper-validator-identifier": "^7.28.5",
+ "@babel/traverse": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -335,22 +404,22 @@
}
},
"node_modules/@babel/helper-optimise-call-expression": {
- "version": "7.25.9",
- "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.25.9.tgz",
- "integrity": "sha512-FIpuNaz5ow8VyrYcnXQTDRGvV6tTjkNtCK/RYNDXGSLlUD6cBuQTSw43CShGxjvfBTfcUA/r6UhUCbtYqkhcuQ==",
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz",
+ "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/types": "^7.25.9"
+ "@babel/types": "^7.27.1"
},
"engines": {
"node": ">=6.9.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==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz",
+ "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==",
"dev": true,
"license": "MIT",
"engines": {
@@ -358,15 +427,15 @@
}
},
"node_modules/@babel/helper-remap-async-to-generator": {
- "version": "7.25.9",
- "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.25.9.tgz",
- "integrity": "sha512-IZtukuUeBbhgOcaW2s06OXTzVNJR0ybm4W5xC1opWFFJMZbwRj5LCk+ByYH7WdZPZTt8KnFwA8pvjN2yqcPlgw==",
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.27.1.tgz",
+ "integrity": "sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-annotate-as-pure": "^7.25.9",
- "@babel/helper-wrap-function": "^7.25.9",
- "@babel/traverse": "^7.25.9"
+ "@babel/helper-annotate-as-pure": "^7.27.1",
+ "@babel/helper-wrap-function": "^7.27.1",
+ "@babel/traverse": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
@@ -376,15 +445,15 @@
}
},
"node_modules/@babel/helper-replace-supers": {
- "version": "7.26.5",
- "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.26.5.tgz",
- "integrity": "sha512-bJ6iIVdYX1YooY2X7w1q6VITt+LnUILtNk7zT78ykuwStx8BauCzxvFqFaHjOpW1bVnSUM1PN1f0p5P21wHxvg==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz",
+ "integrity": "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-member-expression-to-functions": "^7.25.9",
- "@babel/helper-optimise-call-expression": "^7.25.9",
- "@babel/traverse": "^7.26.5"
+ "@babel/helper-member-expression-to-functions": "^7.28.5",
+ "@babel/helper-optimise-call-expression": "^7.27.1",
+ "@babel/traverse": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -394,14 +463,14 @@
}
},
"node_modules/@babel/helper-skip-transparent-expression-wrappers": {
- "version": "7.25.9",
- "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.25.9.tgz",
- "integrity": "sha512-K4Du3BFa3gvyhzgPcntrkDgZzQaq6uozzcpGbOO1OEJaI+EJdqWIMTLgFgQf6lrfiDFo5FU+BxKepI9RmZqahA==",
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz",
+ "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/traverse": "^7.25.9",
- "@babel/types": "^7.25.9"
+ "@babel/traverse": "^7.27.1",
+ "@babel/types": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
@@ -418,9 +487,9 @@
}
},
"node_modules/@babel/helper-validator-identifier": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz",
- "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==",
+ "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": {
@@ -438,42 +507,42 @@
}
},
"node_modules/@babel/helper-wrap-function": {
- "version": "7.25.9",
- "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.25.9.tgz",
- "integrity": "sha512-ETzz9UTjQSTmw39GboatdymDq4XIQbR8ySgVrylRhPOFpsd+JrKHIuF0de7GCWmem+T4uC5z7EZguod7Wj4A4g==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.28.6.tgz",
+ "integrity": "sha512-z+PwLziMNBeSQJonizz2AGnndLsP2DeGHIxDAn+wdHOGuo4Fo1x1HBPPXeE9TAOPHNNWQKCSlA2VZyYyyibDnQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/template": "^7.25.9",
- "@babel/traverse": "^7.25.9",
- "@babel/types": "^7.25.9"
+ "@babel/template": "^7.28.6",
+ "@babel/traverse": "^7.28.6",
+ "@babel/types": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helpers": {
- "version": "7.28.3",
- "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.3.tgz",
- "integrity": "sha512-PTNtvUQihsAsDHMOP5pfobP8C6CM4JWXmP8DrEIt46c3r2bf87Ua1zoqevsMo9g+tWDwgWrFP5EIxuBx5RudAw==",
+ "version": "7.29.2",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz",
+ "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/template": "^7.27.2",
- "@babel/types": "^7.28.2"
+ "@babel/template": "^7.28.6",
+ "@babel/types": "^7.29.0"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
- "version": "7.28.3",
- "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.3.tgz",
- "integrity": "sha512-7+Ey1mAgYqFAx2h0RuoxcQT5+MlG3GTV0TQrgr7/ZliKsm/MNDxVVutlWaziMq7wJNAz8MTqz55XLpWvva6StA==",
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz",
+ "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/types": "^7.28.2"
+ "@babel/types": "^7.29.0"
},
"bin": {
"parser": "bin/babel-parser.js"
@@ -483,14 +552,14 @@
}
},
"node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": {
- "version": "7.25.9",
- "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.25.9.tgz",
- "integrity": "sha512-ZkRyVkThtxQ/J6nv3JFYv1RYY+JT5BvU0y3k5bWrmuG4woXypRa4PXmm9RhOwodRkYFWqC0C0cqcJ4OqR7kW+g==",
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.28.5.tgz",
+ "integrity": "sha512-87GDMS3tsmMSi/3bWOte1UblL+YUTFMV8SZPZ2eSEL17s74Cw/l63rR6NmGVKMYW2GYi85nE+/d6Hw5N0bEk2Q==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.25.9",
- "@babel/traverse": "^7.25.9"
+ "@babel/helper-plugin-utils": "^7.27.1",
+ "@babel/traverse": "^7.28.5"
},
"engines": {
"node": ">=6.9.0"
@@ -500,13 +569,13 @@
}
},
"node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": {
- "version": "7.25.9",
- "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.25.9.tgz",
- "integrity": "sha512-MrGRLZxLD/Zjj0gdU15dfs+HH/OXvnw/U4jJD8vpcP2CJQapPEv1IWwjc/qMg7ItBlPwSv1hRBbb7LeuANdcnw==",
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.27.1.tgz",
+ "integrity": "sha512-qNeq3bCKnGgLkEXUuFry6dPlGfCdQNZbn7yUAPCInwAJHMU7THJfrBSozkcWq5sNM6RcF3S8XyQL2A52KNR9IA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.25.9"
+ "@babel/helper-plugin-utils": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
@@ -516,13 +585,13 @@
}
},
"node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": {
- "version": "7.25.9",
- "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.25.9.tgz",
- "integrity": "sha512-2qUwwfAFpJLZqxd02YW9btUCZHl+RFvdDkNfZwaIJrvB8Tesjsk8pEQkTvGwZXLqXUx/2oyY3ySRhm6HOXuCug==",
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.27.1.tgz",
+ "integrity": "sha512-g4L7OYun04N1WyqMNjldFwlfPCLVkgB54A/YCXICZYBsvJJE3kByKv9c9+R/nAfmIfjl2rKYLNyMHboYbZaWaA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.25.9"
+ "@babel/helper-plugin-utils": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
@@ -532,15 +601,15 @@
}
},
"node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": {
- "version": "7.25.9",
- "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.25.9.tgz",
- "integrity": "sha512-6xWgLZTJXwilVjlnV7ospI3xi+sl8lN8rXXbBD6vYn3UYDlGsag8wrZkKcSI8G6KgqKP7vNFaDgeDnfAABq61g==",
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.27.1.tgz",
+ "integrity": "sha512-oO02gcONcD5O1iTLi/6frMJBIwWEHceWGSGqrpCmEL8nogiS6J9PBlE48CaK20/Jx1LuRml9aDftLgdjXT8+Cw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.25.9",
- "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9",
- "@babel/plugin-transform-optional-chaining": "^7.25.9"
+ "@babel/helper-plugin-utils": "^7.27.1",
+ "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1",
+ "@babel/plugin-transform-optional-chaining": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
@@ -550,14 +619,14 @@
}
},
"node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": {
- "version": "7.25.9",
- "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.25.9.tgz",
- "integrity": "sha512-aLnMXYPnzwwqhYSCyXfKkIkYgJ8zv9RK+roo9DkTXz38ynIhd9XCbN08s3MGvqL2MYGVUGdRQLL/JqBIeJhJBg==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.28.6.tgz",
+ "integrity": "sha512-a0aBScVTlNaiUe35UtfxAN7A/tehvvG4/ByO6+46VPKTRSlfnAFsgKy0FUh+qAkQrDTmhDkT+IBOKlOoMUxQ0g==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.25.9",
- "@babel/traverse": "^7.25.9"
+ "@babel/helper-plugin-utils": "^7.28.6",
+ "@babel/traverse": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -580,13 +649,13 @@
}
},
"node_modules/@babel/plugin-syntax-import-assertions": {
- "version": "7.26.0",
- "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.26.0.tgz",
- "integrity": "sha512-QCWT5Hh830hK5EQa7XzuqIkQU9tT/whqbDz7kuaZMHFl1inRRg7JnuAEOQ0Ur0QUl0NufCk1msK2BeY79Aj/eg==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.28.6.tgz",
+ "integrity": "sha512-pSJUpFHdx9z5nqTSirOCMtYVP2wFgoWhP0p3g8ONK/4IHhLIBd0B9NYqAvIUAhq+OkhO4VM1tENCt0cjlsNShw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.25.9"
+ "@babel/helper-plugin-utils": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -596,13 +665,13 @@
}
},
"node_modules/@babel/plugin-syntax-import-attributes": {
- "version": "7.26.0",
- "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.26.0.tgz",
- "integrity": "sha512-e2dttdsJ1ZTpi3B9UYGLw41hifAubg19AtCu/2I/F1QNVclOBr1dYpTdmdyZ84Xiz43BS/tCUkMAZNLv12Pi+A==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz",
+ "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.25.9"
+ "@babel/helper-plugin-utils": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -629,13 +698,13 @@
}
},
"node_modules/@babel/plugin-transform-arrow-functions": {
- "version": "7.25.9",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.25.9.tgz",
- "integrity": "sha512-6jmooXYIwn9ca5/RylZADJ+EnSxVUS5sjeJ9UPk6RWRzXCmOJCy6dqItPJFpw2cuCangPK4OYr5uhGKcmrm5Qg==",
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.27.1.tgz",
+ "integrity": "sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.25.9"
+ "@babel/helper-plugin-utils": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
@@ -645,15 +714,15 @@
}
},
"node_modules/@babel/plugin-transform-async-generator-functions": {
- "version": "7.26.8",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.26.8.tgz",
- "integrity": "sha512-He9Ej2X7tNf2zdKMAGOsmg2MrFc+hfoAhd3po4cWfo/NWjzEAKa0oQruj1ROVUdl0e6fb6/kE/G3SSxE0lRJOg==",
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.29.0.tgz",
+ "integrity": "sha512-va0VdWro4zlBr2JsXC+ofCPB2iG12wPtVGTWFx2WLDOM3nYQZZIGP82qku2eW/JR83sD+k2k+CsNtyEbUqhU6w==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.26.5",
- "@babel/helper-remap-async-to-generator": "^7.25.9",
- "@babel/traverse": "^7.26.8"
+ "@babel/helper-plugin-utils": "^7.28.6",
+ "@babel/helper-remap-async-to-generator": "^7.27.1",
+ "@babel/traverse": "^7.29.0"
},
"engines": {
"node": ">=6.9.0"
@@ -663,15 +732,15 @@
}
},
"node_modules/@babel/plugin-transform-async-to-generator": {
- "version": "7.25.9",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.25.9.tgz",
- "integrity": "sha512-NT7Ejn7Z/LjUH0Gv5KsBCxh7BH3fbLTV0ptHvpeMvrt3cPThHfJfst9Wrb7S8EvJ7vRTFI7z+VAvFVEQn/m5zQ==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.28.6.tgz",
+ "integrity": "sha512-ilTRcmbuXjsMmcZ3HASTe4caH5Tpo93PkTxF9oG2VZsSWsahydmcEHhix9Ik122RcTnZnUzPbmux4wh1swfv7g==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-module-imports": "^7.25.9",
- "@babel/helper-plugin-utils": "^7.25.9",
- "@babel/helper-remap-async-to-generator": "^7.25.9"
+ "@babel/helper-module-imports": "^7.28.6",
+ "@babel/helper-plugin-utils": "^7.28.6",
+ "@babel/helper-remap-async-to-generator": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
@@ -681,13 +750,13 @@
}
},
"node_modules/@babel/plugin-transform-block-scoped-functions": {
- "version": "7.26.5",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.26.5.tgz",
- "integrity": "sha512-chuTSY+hq09+/f5lMj8ZSYgCFpppV2CbYrhNFJ1BFoXpiWPnnAb7R0MqrafCpN8E1+YRrtM1MXZHJdIx8B6rMQ==",
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.27.1.tgz",
+ "integrity": "sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.26.5"
+ "@babel/helper-plugin-utils": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
@@ -697,13 +766,13 @@
}
},
"node_modules/@babel/plugin-transform-block-scoping": {
- "version": "7.27.0",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.27.0.tgz",
- "integrity": "sha512-u1jGphZ8uDI2Pj/HJj6YQ6XQLZCNjOlprjxB5SVz6rq2T6SwAR+CdrWK0CP7F+9rDVMXdB0+r6Am5G5aobOjAQ==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.6.tgz",
+ "integrity": "sha512-tt/7wOtBmwHPNMPu7ax4pdPz6shjFrmHDghvNC+FG9Qvj7D6mJcoRQIF5dy4njmxR941l6rgtvfSB2zX3VlUIw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.26.5"
+ "@babel/helper-plugin-utils": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -713,14 +782,14 @@
}
},
"node_modules/@babel/plugin-transform-class-properties": {
- "version": "7.25.9",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.25.9.tgz",
- "integrity": "sha512-bbMAII8GRSkcd0h0b4X+36GksxuheLFjP65ul9w6C3KgAamI3JqErNgSrosX6ZPj+Mpim5VvEbawXxJCyEUV3Q==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.28.6.tgz",
+ "integrity": "sha512-dY2wS3I2G7D697VHndN91TJr8/AAfXQNt5ynCTI/MpxMsSzHp+52uNivYT5wCPax3whc47DR8Ba7cmlQMg24bw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-create-class-features-plugin": "^7.25.9",
- "@babel/helper-plugin-utils": "^7.25.9"
+ "@babel/helper-create-class-features-plugin": "^7.28.6",
+ "@babel/helper-plugin-utils": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -730,14 +799,14 @@
}
},
"node_modules/@babel/plugin-transform-class-static-block": {
- "version": "7.26.0",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.26.0.tgz",
- "integrity": "sha512-6J2APTs7BDDm+UMqP1useWqhcRAXo0WIoVj26N7kPFB6S73Lgvyka4KTZYIxtgYXiN5HTyRObA72N2iu628iTQ==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.28.6.tgz",
+ "integrity": "sha512-rfQ++ghVwTWTqQ7w8qyDxL1XGihjBss4CmTgGRCTAC9RIbhVpyp4fOeZtta0Lbf+dTNIVJer6ych2ibHwkZqsQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-create-class-features-plugin": "^7.25.9",
- "@babel/helper-plugin-utils": "^7.25.9"
+ "@babel/helper-create-class-features-plugin": "^7.28.6",
+ "@babel/helper-plugin-utils": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -747,18 +816,18 @@
}
},
"node_modules/@babel/plugin-transform-classes": {
- "version": "7.25.9",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.25.9.tgz",
- "integrity": "sha512-mD8APIXmseE7oZvZgGABDyM34GUmK45Um2TXiBUt7PnuAxrgoSVf123qUzPxEr/+/BHrRn5NMZCdE2m/1F8DGg==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.6.tgz",
+ "integrity": "sha512-EF5KONAqC5zAqT783iMGuM2ZtmEBy+mJMOKl2BCvPZ2lVrwvXnB6o+OBWCS+CoeCCpVRF2sA2RBKUxvT8tQT5Q==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-annotate-as-pure": "^7.25.9",
- "@babel/helper-compilation-targets": "^7.25.9",
- "@babel/helper-plugin-utils": "^7.25.9",
- "@babel/helper-replace-supers": "^7.25.9",
- "@babel/traverse": "^7.25.9",
- "globals": "^11.1.0"
+ "@babel/helper-annotate-as-pure": "^7.27.3",
+ "@babel/helper-compilation-targets": "^7.28.6",
+ "@babel/helper-globals": "^7.28.0",
+ "@babel/helper-plugin-utils": "^7.28.6",
+ "@babel/helper-replace-supers": "^7.28.6",
+ "@babel/traverse": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -768,14 +837,14 @@
}
},
"node_modules/@babel/plugin-transform-computed-properties": {
- "version": "7.25.9",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.25.9.tgz",
- "integrity": "sha512-HnBegGqXZR12xbcTHlJ9HGxw1OniltT26J5YpfruGqtUHlz/xKf/G2ak9e+t0rVqrjXa9WOhvYPz1ERfMj23AA==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.28.6.tgz",
+ "integrity": "sha512-bcc3k0ijhHbc2lEfpFHgx7eYw9KNXqOerKWfzbxEHUGKnS3sz9C4CNL9OiFN1297bDNfUiSO7DaLzbvHQQQ1BQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.25.9",
- "@babel/template": "^7.25.9"
+ "@babel/helper-plugin-utils": "^7.28.6",
+ "@babel/template": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -785,13 +854,14 @@
}
},
"node_modules/@babel/plugin-transform-destructuring": {
- "version": "7.25.9",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.25.9.tgz",
- "integrity": "sha512-WkCGb/3ZxXepmMiX101nnGiU+1CAdut8oHyEOHxkKuS1qKpU2SMXE2uSvfz8PBuLd49V6LEsbtyPhWC7fnkgvQ==",
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.28.5.tgz",
+ "integrity": "sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.25.9"
+ "@babel/helper-plugin-utils": "^7.27.1",
+ "@babel/traverse": "^7.28.5"
},
"engines": {
"node": ">=6.9.0"
@@ -801,14 +871,14 @@
}
},
"node_modules/@babel/plugin-transform-dotall-regex": {
- "version": "7.25.9",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.25.9.tgz",
- "integrity": "sha512-t7ZQ7g5trIgSRYhI9pIJtRl64KHotutUJsh4Eze5l7olJv+mRSg4/MmbZ0tv1eeqRbdvo/+trvJD/Oc5DmW2cA==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.28.6.tgz",
+ "integrity": "sha512-SljjowuNKB7q5Oayv4FoPzeB74g3QgLt8IVJw9ADvWy3QnUb/01aw8I4AVv8wYnPvQz2GDDZ/g3GhcNyDBI4Bg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-create-regexp-features-plugin": "^7.25.9",
- "@babel/helper-plugin-utils": "^7.25.9"
+ "@babel/helper-create-regexp-features-plugin": "^7.28.5",
+ "@babel/helper-plugin-utils": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -818,13 +888,13 @@
}
},
"node_modules/@babel/plugin-transform-duplicate-keys": {
- "version": "7.25.9",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.25.9.tgz",
- "integrity": "sha512-LZxhJ6dvBb/f3x8xwWIuyiAHy56nrRG3PeYTpBkkzkYRRQ6tJLu68lEF5VIqMUZiAV7a8+Tb78nEoMCMcqjXBw==",
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.27.1.tgz",
+ "integrity": "sha512-MTyJk98sHvSs+cvZ4nOauwTTG1JeonDjSGvGGUNHreGQns+Mpt6WX/dVzWBHgg+dYZhkC4X+zTDfkTU+Vy9y7Q==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.25.9"
+ "@babel/helper-plugin-utils": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
@@ -834,14 +904,14 @@
}
},
"node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": {
- "version": "7.25.9",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.25.9.tgz",
- "integrity": "sha512-0UfuJS0EsXbRvKnwcLjFtJy/Sxc5J5jhLHnFhy7u4zih97Hz6tJkLU+O+FMMrNZrosUPxDi6sYxJ/EA8jDiAog==",
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.29.0.tgz",
+ "integrity": "sha512-zBPcW2lFGxdiD8PUnPwJjag2J9otbcLQzvbiOzDxpYXyCuYX9agOwMPGn1prVH0a4qzhCKu24rlH4c1f7yA8rw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-create-regexp-features-plugin": "^7.25.9",
- "@babel/helper-plugin-utils": "^7.25.9"
+ "@babel/helper-create-regexp-features-plugin": "^7.28.5",
+ "@babel/helper-plugin-utils": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -851,13 +921,30 @@
}
},
"node_modules/@babel/plugin-transform-dynamic-import": {
- "version": "7.25.9",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.25.9.tgz",
- "integrity": "sha512-GCggjexbmSLaFhqsojeugBpeaRIgWNTcgKVq/0qIteFEqY2A+b9QidYadrWlnbWQUrW5fn+mCvf3tr7OeBFTyg==",
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.27.1.tgz",
+ "integrity": "sha512-MHzkWQcEmjzzVW9j2q8LGjwGWpG2mjwaaB0BNQwst3FIjqsg8Ct/mIZlvSPJvfi9y2AC8mi/ktxbFVL9pZ1I4A==",
+ "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-explicit-resource-management": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.28.6.tgz",
+ "integrity": "sha512-Iao5Konzx2b6g7EPqTy40UZbcdXE126tTxVFr/nAIj+WItNxjKSYTEw3RC+A2/ZetmdJsgueL1KhaMCQHkLPIg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.25.9"
+ "@babel/helper-plugin-utils": "^7.28.6",
+ "@babel/plugin-transform-destructuring": "^7.28.5"
},
"engines": {
"node": ">=6.9.0"
@@ -867,13 +954,13 @@
}
},
"node_modules/@babel/plugin-transform-exponentiation-operator": {
- "version": "7.26.3",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.26.3.tgz",
- "integrity": "sha512-7CAHcQ58z2chuXPWblnn1K6rLDnDWieghSOEmqQsrBenH0P9InCUtOJYD89pvngljmZlJcz3fcmgYsXFNGa1ZQ==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.28.6.tgz",
+ "integrity": "sha512-WitabqiGjV/vJ0aPOLSFfNY1u9U3R7W36B03r5I2KoNix+a3sOhJ3pKFB3R5It9/UiK78NiO0KE9P21cMhlPkw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.25.9"
+ "@babel/helper-plugin-utils": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -883,13 +970,13 @@
}
},
"node_modules/@babel/plugin-transform-export-namespace-from": {
- "version": "7.25.9",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.25.9.tgz",
- "integrity": "sha512-2NsEz+CxzJIVOPx2o9UsW1rXLqtChtLoVnwYHHiB04wS5sgn7mrV45fWMBX0Kk+ub9uXytVYfNP2HjbVbCB3Ww==",
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.27.1.tgz",
+ "integrity": "sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.25.9"
+ "@babel/helper-plugin-utils": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
@@ -899,14 +986,14 @@
}
},
"node_modules/@babel/plugin-transform-for-of": {
- "version": "7.26.9",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.26.9.tgz",
- "integrity": "sha512-Hry8AusVm8LW5BVFgiyUReuoGzPUpdHQQqJY5bZnbbf+ngOHWuCuYFKw/BqaaWlvEUrF91HMhDtEaI1hZzNbLg==",
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.27.1.tgz",
+ "integrity": "sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.26.5",
- "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9"
+ "@babel/helper-plugin-utils": "^7.27.1",
+ "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
@@ -916,15 +1003,15 @@
}
},
"node_modules/@babel/plugin-transform-function-name": {
- "version": "7.25.9",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.25.9.tgz",
- "integrity": "sha512-8lP+Yxjv14Vc5MuWBpJsoUCd3hD6V9DgBon2FVYL4jJgbnVQ9fTgYmonchzZJOVNgzEgbxp4OwAf6xz6M/14XA==",
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.27.1.tgz",
+ "integrity": "sha512-1bQeydJF9Nr1eBCMMbC+hdwmRlsv5XYOMu03YSWFwNs0HsAmtSxxF1fyuYPqemVldVyFmlCU7w8UE14LupUSZQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-compilation-targets": "^7.25.9",
- "@babel/helper-plugin-utils": "^7.25.9",
- "@babel/traverse": "^7.25.9"
+ "@babel/helper-compilation-targets": "^7.27.1",
+ "@babel/helper-plugin-utils": "^7.27.1",
+ "@babel/traverse": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
@@ -934,13 +1021,13 @@
}
},
"node_modules/@babel/plugin-transform-json-strings": {
- "version": "7.25.9",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.25.9.tgz",
- "integrity": "sha512-xoTMk0WXceiiIvsaquQQUaLLXSW1KJ159KP87VilruQm0LNNGxWzahxSS6T6i4Zg3ezp4vA4zuwiNUR53qmQAw==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.28.6.tgz",
+ "integrity": "sha512-Nr+hEN+0geQkzhbdgQVPoqr47lZbm+5fCUmO70722xJZd0Mvb59+33QLImGj6F+DkK3xgDi1YVysP8whD6FQAw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.25.9"
+ "@babel/helper-plugin-utils": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -950,13 +1037,13 @@
}
},
"node_modules/@babel/plugin-transform-literals": {
- "version": "7.25.9",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.25.9.tgz",
- "integrity": "sha512-9N7+2lFziW8W9pBl2TzaNht3+pgMIRP74zizeCSrtnSKVdUl8mAjjOP2OOVQAfZ881P2cNjDj1uAMEdeD50nuQ==",
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.27.1.tgz",
+ "integrity": "sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.25.9"
+ "@babel/helper-plugin-utils": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
@@ -966,13 +1053,13 @@
}
},
"node_modules/@babel/plugin-transform-logical-assignment-operators": {
- "version": "7.25.9",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.25.9.tgz",
- "integrity": "sha512-wI4wRAzGko551Y8eVf6iOY9EouIDTtPb0ByZx+ktDGHwv6bHFimrgJM/2T021txPZ2s4c7bqvHbd+vXG6K948Q==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.28.6.tgz",
+ "integrity": "sha512-+anKKair6gpi8VsM/95kmomGNMD0eLz1NQ8+Pfw5sAwWH9fGYXT50E55ZpV0pHUHWf6IUTWPM+f/7AAff+wr9A==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.25.9"
+ "@babel/helper-plugin-utils": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -982,13 +1069,13 @@
}
},
"node_modules/@babel/plugin-transform-member-expression-literals": {
- "version": "7.25.9",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.25.9.tgz",
- "integrity": "sha512-PYazBVfofCQkkMzh2P6IdIUaCEWni3iYEerAsRWuVd8+jlM1S9S9cz1dF9hIzyoZ8IA3+OwVYIp9v9e+GbgZhA==",
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.27.1.tgz",
+ "integrity": "sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.25.9"
+ "@babel/helper-plugin-utils": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
@@ -998,14 +1085,14 @@
}
},
"node_modules/@babel/plugin-transform-modules-amd": {
- "version": "7.25.9",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.25.9.tgz",
- "integrity": "sha512-g5T11tnI36jVClQlMlt4qKDLlWnG5pP9CSM4GhdRciTNMRgkfpo5cR6b4rGIOYPgRRuFAvwjPQ/Yk+ql4dyhbw==",
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.27.1.tgz",
+ "integrity": "sha512-iCsytMg/N9/oFq6n+gFTvUYDZQOMK5kEdeYxmxt91fcJGycfxVP9CnrxoliM0oumFERba2i8ZtwRUCMhvP1LnA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-module-transforms": "^7.25.9",
- "@babel/helper-plugin-utils": "^7.25.9"
+ "@babel/helper-module-transforms": "^7.27.1",
+ "@babel/helper-plugin-utils": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
@@ -1015,14 +1102,14 @@
}
},
"node_modules/@babel/plugin-transform-modules-commonjs": {
- "version": "7.26.3",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.26.3.tgz",
- "integrity": "sha512-MgR55l4q9KddUDITEzEFYn5ZsGDXMSsU9E+kh7fjRXTIC3RHqfCo8RPRbyReYJh44HQ/yomFkqbOFohXvDCiIQ==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.28.6.tgz",
+ "integrity": "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-module-transforms": "^7.26.0",
- "@babel/helper-plugin-utils": "^7.25.9"
+ "@babel/helper-module-transforms": "^7.28.6",
+ "@babel/helper-plugin-utils": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -1032,16 +1119,16 @@
}
},
"node_modules/@babel/plugin-transform-modules-systemjs": {
- "version": "7.25.9",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.25.9.tgz",
- "integrity": "sha512-hyss7iIlH/zLHaehT+xwiymtPOpsiwIIRlCAOwBB04ta5Tt+lNItADdlXw3jAWZ96VJ2jlhl/c+PNIQPKNfvcA==",
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.0.tgz",
+ "integrity": "sha512-PrujnVFbOdUpw4UHiVwKvKRLMMic8+eC0CuNlxjsyZUiBjhFdPsewdXCkveh2KqBA9/waD0W1b4hXSOBQJezpQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-module-transforms": "^7.25.9",
- "@babel/helper-plugin-utils": "^7.25.9",
- "@babel/helper-validator-identifier": "^7.25.9",
- "@babel/traverse": "^7.25.9"
+ "@babel/helper-module-transforms": "^7.28.6",
+ "@babel/helper-plugin-utils": "^7.28.6",
+ "@babel/helper-validator-identifier": "^7.28.5",
+ "@babel/traverse": "^7.29.0"
},
"engines": {
"node": ">=6.9.0"
@@ -1051,14 +1138,14 @@
}
},
"node_modules/@babel/plugin-transform-modules-umd": {
- "version": "7.25.9",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.25.9.tgz",
- "integrity": "sha512-bS9MVObUgE7ww36HEfwe6g9WakQ0KF07mQF74uuXdkoziUPfKyu/nIm663kz//e5O1nPInPFx36z7WJmJ4yNEw==",
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.27.1.tgz",
+ "integrity": "sha512-iQBE/xC5BV1OxJbp6WG7jq9IWiD+xxlZhLrdwpPkTX3ydmXdvoCpyfJN7acaIBZaOqTfr76pgzqBJflNbeRK+w==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-module-transforms": "^7.25.9",
- "@babel/helper-plugin-utils": "^7.25.9"
+ "@babel/helper-module-transforms": "^7.27.1",
+ "@babel/helper-plugin-utils": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
@@ -1068,14 +1155,14 @@
}
},
"node_modules/@babel/plugin-transform-named-capturing-groups-regex": {
- "version": "7.25.9",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.25.9.tgz",
- "integrity": "sha512-oqB6WHdKTGl3q/ItQhpLSnWWOpjUJLsOCLVyeFgeTktkBSCiurvPOsyt93gibI9CmuKvTUEtWmG5VhZD+5T/KA==",
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.29.0.tgz",
+ "integrity": "sha512-1CZQA5KNAD6ZYQLPw7oi5ewtDNxH/2vuCh+6SmvgDfhumForvs8a1o9n0UrEoBD8HU4djO2yWngTQlXl1NDVEQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-create-regexp-features-plugin": "^7.25.9",
- "@babel/helper-plugin-utils": "^7.25.9"
+ "@babel/helper-create-regexp-features-plugin": "^7.28.5",
+ "@babel/helper-plugin-utils": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -1085,13 +1172,13 @@
}
},
"node_modules/@babel/plugin-transform-new-target": {
- "version": "7.25.9",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.25.9.tgz",
- "integrity": "sha512-U/3p8X1yCSoKyUj2eOBIx3FOn6pElFOKvAAGf8HTtItuPyB+ZeOqfn+mvTtg9ZlOAjsPdK3ayQEjqHjU/yLeVQ==",
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.27.1.tgz",
+ "integrity": "sha512-f6PiYeqXQ05lYq3TIfIDu/MtliKUbNwkGApPUvyo6+tc7uaR4cPjPe7DFPr15Uyycg2lZU6btZ575CuQoYh7MQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.25.9"
+ "@babel/helper-plugin-utils": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
@@ -1101,13 +1188,13 @@
}
},
"node_modules/@babel/plugin-transform-nullish-coalescing-operator": {
- "version": "7.26.6",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.26.6.tgz",
- "integrity": "sha512-CKW8Vu+uUZneQCPtXmSBUC6NCAUdya26hWCElAWh5mVSlSRsmiCPUUDKb3Z0szng1hiAJa098Hkhg9o4SE35Qw==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.28.6.tgz",
+ "integrity": "sha512-3wKbRgmzYbw24mDJXT7N+ADXw8BC/imU9yo9c9X9NKaLF1fW+e5H1U5QjMUBe4Qo4Ox/o++IyUkl1sVCLgevKg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.26.5"
+ "@babel/helper-plugin-utils": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -1117,13 +1204,13 @@
}
},
"node_modules/@babel/plugin-transform-numeric-separator": {
- "version": "7.25.9",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.25.9.tgz",
- "integrity": "sha512-TlprrJ1GBZ3r6s96Yq8gEQv82s8/5HnCVHtEJScUj90thHQbwe+E5MLhi2bbNHBEJuzrvltXSru+BUxHDoog7Q==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.28.6.tgz",
+ "integrity": "sha512-SJR8hPynj8outz+SlStQSwvziMN4+Bq99it4tMIf5/Caq+3iOc0JtKyse8puvyXkk3eFRIA5ID/XfunGgO5i6w==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.25.9"
+ "@babel/helper-plugin-utils": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -1133,15 +1220,17 @@
}
},
"node_modules/@babel/plugin-transform-object-rest-spread": {
- "version": "7.25.9",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.25.9.tgz",
- "integrity": "sha512-fSaXafEE9CVHPweLYw4J0emp1t8zYTXyzN3UuG+lylqkvYd7RMrsOQ8TYx5RF231be0vqtFC6jnx3UmpJmKBYg==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.28.6.tgz",
+ "integrity": "sha512-5rh+JR4JBC4pGkXLAcYdLHZjXudVxWMXbB6u6+E9lRL5TrGVbHt1TjxGbZ8CkmYw9zjkB7jutzOROArsqtncEA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-compilation-targets": "^7.25.9",
- "@babel/helper-plugin-utils": "^7.25.9",
- "@babel/plugin-transform-parameters": "^7.25.9"
+ "@babel/helper-compilation-targets": "^7.28.6",
+ "@babel/helper-plugin-utils": "^7.28.6",
+ "@babel/plugin-transform-destructuring": "^7.28.5",
+ "@babel/plugin-transform-parameters": "^7.27.7",
+ "@babel/traverse": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -1151,14 +1240,14 @@
}
},
"node_modules/@babel/plugin-transform-object-super": {
- "version": "7.25.9",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.25.9.tgz",
- "integrity": "sha512-Kj/Gh+Rw2RNLbCK1VAWj2U48yxxqL2x0k10nPtSdRa0O2xnHXalD0s+o1A6a0W43gJ00ANo38jxkQreckOzv5A==",
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.27.1.tgz",
+ "integrity": "sha512-SFy8S9plRPbIcxlJ8A6mT/CxFdJx/c04JEctz4jf8YZaVS2px34j7NXRrlGlHkN/M2gnpL37ZpGRGVFLd3l8Ng==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.25.9",
- "@babel/helper-replace-supers": "^7.25.9"
+ "@babel/helper-plugin-utils": "^7.27.1",
+ "@babel/helper-replace-supers": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
@@ -1168,13 +1257,13 @@
}
},
"node_modules/@babel/plugin-transform-optional-catch-binding": {
- "version": "7.25.9",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.25.9.tgz",
- "integrity": "sha512-qM/6m6hQZzDcZF3onzIhZeDHDO43bkNNlOX0i8n3lR6zLbu0GN2d8qfM/IERJZYauhAHSLHy39NF0Ctdvcid7g==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.28.6.tgz",
+ "integrity": "sha512-R8ja/Pyrv0OGAvAXQhSTmWyPJPml+0TMqXlO5w+AsMEiwb2fg3WkOvob7UxFSL3OIttFSGSRFKQsOhJ/X6HQdQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.25.9"
+ "@babel/helper-plugin-utils": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -1184,14 +1273,14 @@
}
},
"node_modules/@babel/plugin-transform-optional-chaining": {
- "version": "7.25.9",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.25.9.tgz",
- "integrity": "sha512-6AvV0FsLULbpnXeBjrY4dmWF8F7gf8QnvTEoO/wX/5xm/xE1Xo8oPuD3MPS+KS9f9XBEAWN7X1aWr4z9HdOr7A==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.28.6.tgz",
+ "integrity": "sha512-A4zobikRGJTsX9uqVFdafzGkqD30t26ck2LmOzAuLL8b2x6k3TIqRiT2xVvA9fNmFeTX484VpsdgmKNA0bS23w==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.25.9",
- "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9"
+ "@babel/helper-plugin-utils": "^7.28.6",
+ "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
@@ -1201,13 +1290,13 @@
}
},
"node_modules/@babel/plugin-transform-parameters": {
- "version": "7.25.9",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.25.9.tgz",
- "integrity": "sha512-wzz6MKwpnshBAiRmn4jR8LYz/g8Ksg0o80XmwZDlordjwEk9SxBzTWC7F5ef1jhbrbOW2DJ5J6ayRukrJmnr0g==",
+ "version": "7.27.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.27.7.tgz",
+ "integrity": "sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.25.9"
+ "@babel/helper-plugin-utils": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
@@ -1217,14 +1306,14 @@
}
},
"node_modules/@babel/plugin-transform-private-methods": {
- "version": "7.25.9",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.25.9.tgz",
- "integrity": "sha512-D/JUozNpQLAPUVusvqMxyvjzllRaF8/nSrP1s2YGQT/W4LHK4xxsMcHjhOGTS01mp9Hda8nswb+FblLdJornQw==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.28.6.tgz",
+ "integrity": "sha512-piiuapX9CRv7+0st8lmuUlRSmX6mBcVeNQ1b4AYzJxfCMuBfB0vBXDiGSmm03pKJw1v6cZ8KSeM+oUnM6yAExg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-create-class-features-plugin": "^7.25.9",
- "@babel/helper-plugin-utils": "^7.25.9"
+ "@babel/helper-create-class-features-plugin": "^7.28.6",
+ "@babel/helper-plugin-utils": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -1234,15 +1323,15 @@
}
},
"node_modules/@babel/plugin-transform-private-property-in-object": {
- "version": "7.25.9",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.25.9.tgz",
- "integrity": "sha512-Evf3kcMqzXA3xfYJmZ9Pg1OvKdtqsDMSWBDzZOPLvHiTt36E75jLDQo5w1gtRU95Q4E5PDttrTf25Fw8d/uWLw==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.28.6.tgz",
+ "integrity": "sha512-b97jvNSOb5+ehyQmBpmhOCiUC5oVK4PMnpRvO7+ymFBoqYjeDHIU9jnrNUuwHOiL9RpGDoKBpSViarV+BU+eVA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-annotate-as-pure": "^7.25.9",
- "@babel/helper-create-class-features-plugin": "^7.25.9",
- "@babel/helper-plugin-utils": "^7.25.9"
+ "@babel/helper-annotate-as-pure": "^7.27.3",
+ "@babel/helper-create-class-features-plugin": "^7.28.6",
+ "@babel/helper-plugin-utils": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -1252,13 +1341,13 @@
}
},
"node_modules/@babel/plugin-transform-property-literals": {
- "version": "7.25.9",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.25.9.tgz",
- "integrity": "sha512-IvIUeV5KrS/VPavfSM/Iu+RE6llrHrYIKY1yfCzyO/lMXHQ+p7uGhonmGVisv6tSBSVgWzMBohTcvkC9vQcQFA==",
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.27.1.tgz",
+ "integrity": "sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.25.9"
+ "@babel/helper-plugin-utils": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
@@ -1300,14 +1389,13 @@
}
},
"node_modules/@babel/plugin-transform-regenerator": {
- "version": "7.27.0",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.27.0.tgz",
- "integrity": "sha512-LX/vCajUJQDqE7Aum/ELUMZAY19+cDpghxrnyt5I1tV6X5PyC86AOoWXWFYFeIvauyeSA6/ktn4tQVn/3ZifsA==",
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.0.tgz",
+ "integrity": "sha512-FijqlqMA7DmRdg/aINBSs04y8XNTYw/lr1gJ2WsmBnnaNw1iS43EPkJW+zK7z65auG3AWRFXWj+NcTQwYptUog==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.26.5",
- "regenerator-transform": "^0.15.2"
+ "@babel/helper-plugin-utils": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -1317,14 +1405,14 @@
}
},
"node_modules/@babel/plugin-transform-regexp-modifiers": {
- "version": "7.26.0",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.26.0.tgz",
- "integrity": "sha512-vN6saax7lrA2yA/Pak3sCxuD6F5InBjn9IcrIKQPjpsLvuHYLVroTxjdlVRHjjBWxKOqIwpTXDkOssYT4BFdRw==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.28.6.tgz",
+ "integrity": "sha512-QGWAepm9qxpaIs7UM9FvUSnCGlb8Ua1RhyM4/veAxLwt3gMat/LSGrZixyuj4I6+Kn9iwvqCyPTtbdxanYoWYg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-create-regexp-features-plugin": "^7.25.9",
- "@babel/helper-plugin-utils": "^7.25.9"
+ "@babel/helper-create-regexp-features-plugin": "^7.28.5",
+ "@babel/helper-plugin-utils": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -1334,13 +1422,13 @@
}
},
"node_modules/@babel/plugin-transform-reserved-words": {
- "version": "7.25.9",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.25.9.tgz",
- "integrity": "sha512-7DL7DKYjn5Su++4RXu8puKZm2XBPHyjWLUidaPEkCUBbE7IPcsrkRHggAOOKydH1dASWdcUBxrkOGNxUv5P3Jg==",
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.27.1.tgz",
+ "integrity": "sha512-V2ABPHIJX4kC7HegLkYoDpfg9PVmuWy/i6vUM5eGK22bx4YVFD3M5F0QQnWQoDs6AGsUWTVOopBiMFQgHaSkVw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.25.9"
+ "@babel/helper-plugin-utils": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
@@ -1350,13 +1438,13 @@
}
},
"node_modules/@babel/plugin-transform-shorthand-properties": {
- "version": "7.25.9",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.25.9.tgz",
- "integrity": "sha512-MUv6t0FhO5qHnS/W8XCbHmiRWOphNufpE1IVxhK5kuN3Td9FT1x4rx4K42s3RYdMXCXpfWkGSbCSd0Z64xA7Ng==",
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.27.1.tgz",
+ "integrity": "sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.25.9"
+ "@babel/helper-plugin-utils": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
@@ -1366,14 +1454,14 @@
}
},
"node_modules/@babel/plugin-transform-spread": {
- "version": "7.25.9",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.25.9.tgz",
- "integrity": "sha512-oNknIB0TbURU5pqJFVbOOFspVlrpVwo2H1+HUIsVDvp5VauGGDP1ZEvO8Nn5xyMEs3dakajOxlmkNW7kNgSm6A==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.28.6.tgz",
+ "integrity": "sha512-9U4QObUC0FtJl05AsUcodau/RWDytrU6uKgkxu09mLR9HLDAtUMoPuuskm5huQsoktmsYpI+bGmq+iapDcriKA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.25.9",
- "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9"
+ "@babel/helper-plugin-utils": "^7.28.6",
+ "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
@@ -1383,13 +1471,13 @@
}
},
"node_modules/@babel/plugin-transform-sticky-regex": {
- "version": "7.25.9",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.25.9.tgz",
- "integrity": "sha512-WqBUSgeVwucYDP9U/xNRQam7xV8W5Zf+6Eo7T2SRVUFlhRiMNFdFz58u0KZmCVVqs2i7SHgpRnAhzRNmKfi2uA==",
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.27.1.tgz",
+ "integrity": "sha512-lhInBO5bi/Kowe2/aLdBAawijx+q1pQzicSgnkB6dUPc1+RC8QmJHKf2OjvU+NZWitguJHEaEmbV6VWEouT58g==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.25.9"
+ "@babel/helper-plugin-utils": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
@@ -1399,13 +1487,13 @@
}
},
"node_modules/@babel/plugin-transform-template-literals": {
- "version": "7.26.8",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.26.8.tgz",
- "integrity": "sha512-OmGDL5/J0CJPJZTHZbi2XpO0tyT2Ia7fzpW5GURwdtp2X3fMmN8au/ej6peC/T33/+CRiIpA8Krse8hFGVmT5Q==",
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz",
+ "integrity": "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.26.5"
+ "@babel/helper-plugin-utils": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
@@ -1415,13 +1503,13 @@
}
},
"node_modules/@babel/plugin-transform-typeof-symbol": {
- "version": "7.27.0",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.27.0.tgz",
- "integrity": "sha512-+LLkxA9rKJpNoGsbLnAgOCdESl73vwYn+V6b+5wHbrE7OGKVDPHIQvbFSzqE6rwqaCw2RE+zdJrlLkcf8YOA0w==",
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.27.1.tgz",
+ "integrity": "sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.26.5"
+ "@babel/helper-plugin-utils": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
@@ -1431,13 +1519,13 @@
}
},
"node_modules/@babel/plugin-transform-unicode-escapes": {
- "version": "7.25.9",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.25.9.tgz",
- "integrity": "sha512-s5EDrE6bW97LtxOcGj1Khcx5AaXwiMmi4toFWRDP9/y0Woo6pXC+iyPu/KuhKtfSrNFd7jJB+/fkOtZy6aIC6Q==",
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.27.1.tgz",
+ "integrity": "sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.25.9"
+ "@babel/helper-plugin-utils": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
@@ -1447,14 +1535,14 @@
}
},
"node_modules/@babel/plugin-transform-unicode-property-regex": {
- "version": "7.25.9",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.25.9.tgz",
- "integrity": "sha512-Jt2d8Ga+QwRluxRQ307Vlxa6dMrYEMZCgGxoPR8V52rxPyldHu3hdlHspxaqYmE7oID5+kB+UKUB/eWS+DkkWg==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.28.6.tgz",
+ "integrity": "sha512-4Wlbdl/sIZjzi/8St0evF0gEZrgOswVO6aOzqxh1kDZOl9WmLrHq2HtGhnOJZmHZYKP8WZ1MDLCt5DAWwRo57A==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-create-regexp-features-plugin": "^7.25.9",
- "@babel/helper-plugin-utils": "^7.25.9"
+ "@babel/helper-create-regexp-features-plugin": "^7.28.5",
+ "@babel/helper-plugin-utils": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -1464,14 +1552,14 @@
}
},
"node_modules/@babel/plugin-transform-unicode-regex": {
- "version": "7.25.9",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.25.9.tgz",
- "integrity": "sha512-yoxstj7Rg9dlNn9UQxzk4fcNivwv4nUYz7fYXBaKxvw/lnmPuOm/ikoELygbYq68Bls3D/D+NBPHiLwZdZZ4HA==",
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.27.1.tgz",
+ "integrity": "sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-create-regexp-features-plugin": "^7.25.9",
- "@babel/helper-plugin-utils": "^7.25.9"
+ "@babel/helper-create-regexp-features-plugin": "^7.27.1",
+ "@babel/helper-plugin-utils": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
@@ -1481,14 +1569,14 @@
}
},
"node_modules/@babel/plugin-transform-unicode-sets-regex": {
- "version": "7.25.9",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.25.9.tgz",
- "integrity": "sha512-8BYqO3GeVNHtx69fdPshN3fnzUNLrWdHhk/icSwigksJGczKSizZ+Z6SBCxTs723Fr5VSNorTIK7a+R2tISvwQ==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.28.6.tgz",
+ "integrity": "sha512-/wHc/paTUmsDYN7SZkpWxogTOBNnlx7nBQYfy6JJlCT7G3mVhltk3e++N7zV0XfgGsrqBxd4rJQt9H16I21Y1Q==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-create-regexp-features-plugin": "^7.25.9",
- "@babel/helper-plugin-utils": "^7.25.9"
+ "@babel/helper-create-regexp-features-plugin": "^7.28.5",
+ "@babel/helper-plugin-utils": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -1498,80 +1586,81 @@
}
},
"node_modules/@babel/preset-env": {
- "version": "7.26.9",
- "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.26.9.tgz",
- "integrity": "sha512-vX3qPGE8sEKEAZCWk05k3cpTAE3/nOYca++JA+Rd0z2NCNzabmYvEiSShKzm10zdquOIAVXsy2Ei/DTW34KlKQ==",
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.29.0.tgz",
+ "integrity": "sha512-fNEdfc0yi16lt6IZo2Qxk3knHVdfMYX33czNb4v8yWhemoBhibCpQK/uYHtSKIiO+p/zd3+8fYVXhQdOVV608w==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/compat-data": "^7.26.8",
- "@babel/helper-compilation-targets": "^7.26.5",
- "@babel/helper-plugin-utils": "^7.26.5",
- "@babel/helper-validator-option": "^7.25.9",
- "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.25.9",
- "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.25.9",
- "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.25.9",
- "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.25.9",
- "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.25.9",
+ "@babel/compat-data": "^7.29.0",
+ "@babel/helper-compilation-targets": "^7.28.6",
+ "@babel/helper-plugin-utils": "^7.28.6",
+ "@babel/helper-validator-option": "^7.27.1",
+ "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.28.5",
+ "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.27.1",
+ "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.27.1",
+ "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.27.1",
+ "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.28.6",
"@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2",
- "@babel/plugin-syntax-import-assertions": "^7.26.0",
- "@babel/plugin-syntax-import-attributes": "^7.26.0",
+ "@babel/plugin-syntax-import-assertions": "^7.28.6",
+ "@babel/plugin-syntax-import-attributes": "^7.28.6",
"@babel/plugin-syntax-unicode-sets-regex": "^7.18.6",
- "@babel/plugin-transform-arrow-functions": "^7.25.9",
- "@babel/plugin-transform-async-generator-functions": "^7.26.8",
- "@babel/plugin-transform-async-to-generator": "^7.25.9",
- "@babel/plugin-transform-block-scoped-functions": "^7.26.5",
- "@babel/plugin-transform-block-scoping": "^7.25.9",
- "@babel/plugin-transform-class-properties": "^7.25.9",
- "@babel/plugin-transform-class-static-block": "^7.26.0",
- "@babel/plugin-transform-classes": "^7.25.9",
- "@babel/plugin-transform-computed-properties": "^7.25.9",
- "@babel/plugin-transform-destructuring": "^7.25.9",
- "@babel/plugin-transform-dotall-regex": "^7.25.9",
- "@babel/plugin-transform-duplicate-keys": "^7.25.9",
- "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.25.9",
- "@babel/plugin-transform-dynamic-import": "^7.25.9",
- "@babel/plugin-transform-exponentiation-operator": "^7.26.3",
- "@babel/plugin-transform-export-namespace-from": "^7.25.9",
- "@babel/plugin-transform-for-of": "^7.26.9",
- "@babel/plugin-transform-function-name": "^7.25.9",
- "@babel/plugin-transform-json-strings": "^7.25.9",
- "@babel/plugin-transform-literals": "^7.25.9",
- "@babel/plugin-transform-logical-assignment-operators": "^7.25.9",
- "@babel/plugin-transform-member-expression-literals": "^7.25.9",
- "@babel/plugin-transform-modules-amd": "^7.25.9",
- "@babel/plugin-transform-modules-commonjs": "^7.26.3",
- "@babel/plugin-transform-modules-systemjs": "^7.25.9",
- "@babel/plugin-transform-modules-umd": "^7.25.9",
- "@babel/plugin-transform-named-capturing-groups-regex": "^7.25.9",
- "@babel/plugin-transform-new-target": "^7.25.9",
- "@babel/plugin-transform-nullish-coalescing-operator": "^7.26.6",
- "@babel/plugin-transform-numeric-separator": "^7.25.9",
- "@babel/plugin-transform-object-rest-spread": "^7.25.9",
- "@babel/plugin-transform-object-super": "^7.25.9",
- "@babel/plugin-transform-optional-catch-binding": "^7.25.9",
- "@babel/plugin-transform-optional-chaining": "^7.25.9",
- "@babel/plugin-transform-parameters": "^7.25.9",
- "@babel/plugin-transform-private-methods": "^7.25.9",
- "@babel/plugin-transform-private-property-in-object": "^7.25.9",
- "@babel/plugin-transform-property-literals": "^7.25.9",
- "@babel/plugin-transform-regenerator": "^7.25.9",
- "@babel/plugin-transform-regexp-modifiers": "^7.26.0",
- "@babel/plugin-transform-reserved-words": "^7.25.9",
- "@babel/plugin-transform-shorthand-properties": "^7.25.9",
- "@babel/plugin-transform-spread": "^7.25.9",
- "@babel/plugin-transform-sticky-regex": "^7.25.9",
- "@babel/plugin-transform-template-literals": "^7.26.8",
- "@babel/plugin-transform-typeof-symbol": "^7.26.7",
- "@babel/plugin-transform-unicode-escapes": "^7.25.9",
- "@babel/plugin-transform-unicode-property-regex": "^7.25.9",
- "@babel/plugin-transform-unicode-regex": "^7.25.9",
- "@babel/plugin-transform-unicode-sets-regex": "^7.25.9",
+ "@babel/plugin-transform-arrow-functions": "^7.27.1",
+ "@babel/plugin-transform-async-generator-functions": "^7.29.0",
+ "@babel/plugin-transform-async-to-generator": "^7.28.6",
+ "@babel/plugin-transform-block-scoped-functions": "^7.27.1",
+ "@babel/plugin-transform-block-scoping": "^7.28.6",
+ "@babel/plugin-transform-class-properties": "^7.28.6",
+ "@babel/plugin-transform-class-static-block": "^7.28.6",
+ "@babel/plugin-transform-classes": "^7.28.6",
+ "@babel/plugin-transform-computed-properties": "^7.28.6",
+ "@babel/plugin-transform-destructuring": "^7.28.5",
+ "@babel/plugin-transform-dotall-regex": "^7.28.6",
+ "@babel/plugin-transform-duplicate-keys": "^7.27.1",
+ "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.29.0",
+ "@babel/plugin-transform-dynamic-import": "^7.27.1",
+ "@babel/plugin-transform-explicit-resource-management": "^7.28.6",
+ "@babel/plugin-transform-exponentiation-operator": "^7.28.6",
+ "@babel/plugin-transform-export-namespace-from": "^7.27.1",
+ "@babel/plugin-transform-for-of": "^7.27.1",
+ "@babel/plugin-transform-function-name": "^7.27.1",
+ "@babel/plugin-transform-json-strings": "^7.28.6",
+ "@babel/plugin-transform-literals": "^7.27.1",
+ "@babel/plugin-transform-logical-assignment-operators": "^7.28.6",
+ "@babel/plugin-transform-member-expression-literals": "^7.27.1",
+ "@babel/plugin-transform-modules-amd": "^7.27.1",
+ "@babel/plugin-transform-modules-commonjs": "^7.28.6",
+ "@babel/plugin-transform-modules-systemjs": "^7.29.0",
+ "@babel/plugin-transform-modules-umd": "^7.27.1",
+ "@babel/plugin-transform-named-capturing-groups-regex": "^7.29.0",
+ "@babel/plugin-transform-new-target": "^7.27.1",
+ "@babel/plugin-transform-nullish-coalescing-operator": "^7.28.6",
+ "@babel/plugin-transform-numeric-separator": "^7.28.6",
+ "@babel/plugin-transform-object-rest-spread": "^7.28.6",
+ "@babel/plugin-transform-object-super": "^7.27.1",
+ "@babel/plugin-transform-optional-catch-binding": "^7.28.6",
+ "@babel/plugin-transform-optional-chaining": "^7.28.6",
+ "@babel/plugin-transform-parameters": "^7.27.7",
+ "@babel/plugin-transform-private-methods": "^7.28.6",
+ "@babel/plugin-transform-private-property-in-object": "^7.28.6",
+ "@babel/plugin-transform-property-literals": "^7.27.1",
+ "@babel/plugin-transform-regenerator": "^7.29.0",
+ "@babel/plugin-transform-regexp-modifiers": "^7.28.6",
+ "@babel/plugin-transform-reserved-words": "^7.27.1",
+ "@babel/plugin-transform-shorthand-properties": "^7.27.1",
+ "@babel/plugin-transform-spread": "^7.28.6",
+ "@babel/plugin-transform-sticky-regex": "^7.27.1",
+ "@babel/plugin-transform-template-literals": "^7.27.1",
+ "@babel/plugin-transform-typeof-symbol": "^7.27.1",
+ "@babel/plugin-transform-unicode-escapes": "^7.27.1",
+ "@babel/plugin-transform-unicode-property-regex": "^7.28.6",
+ "@babel/plugin-transform-unicode-regex": "^7.27.1",
+ "@babel/plugin-transform-unicode-sets-regex": "^7.28.6",
"@babel/preset-modules": "0.1.6-no-external-plugins",
- "babel-plugin-polyfill-corejs2": "^0.4.10",
- "babel-plugin-polyfill-corejs3": "^0.11.0",
- "babel-plugin-polyfill-regenerator": "^0.6.1",
- "core-js-compat": "^3.40.0",
+ "babel-plugin-polyfill-corejs2": "^0.4.15",
+ "babel-plugin-polyfill-corejs3": "^0.14.0",
+ "babel-plugin-polyfill-regenerator": "^0.6.6",
+ "core-js-compat": "^3.48.0",
"semver": "^6.3.1"
},
"engines": {
@@ -1609,33 +1698,33 @@
}
},
"node_modules/@babel/template": {
- "version": "7.27.2",
- "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
- "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
+ "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/code-frame": "^7.27.1",
- "@babel/parser": "^7.27.2",
- "@babel/types": "^7.27.1"
+ "@babel/code-frame": "^7.28.6",
+ "@babel/parser": "^7.28.6",
+ "@babel/types": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/traverse": {
- "version": "7.28.3",
- "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.3.tgz",
- "integrity": "sha512-7w4kZYHneL3A6NP2nxzHvT3HCZ7puDZZjFMqDpBPECub79sTtSO5CGXDkKrTQq8ksAwfD/XI2MRFX23njdDaIQ==",
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
+ "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/code-frame": "^7.27.1",
- "@babel/generator": "^7.28.3",
+ "@babel/code-frame": "^7.29.0",
+ "@babel/generator": "^7.29.0",
"@babel/helper-globals": "^7.28.0",
- "@babel/parser": "^7.28.3",
- "@babel/template": "^7.27.2",
- "@babel/types": "^7.28.2",
+ "@babel/parser": "^7.29.0",
+ "@babel/template": "^7.28.6",
+ "@babel/types": "^7.29.0",
"debug": "^4.3.1"
},
"engines": {
@@ -1643,14 +1732,14 @@
}
},
"node_modules/@babel/types": {
- "version": "7.28.2",
- "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.2.tgz",
- "integrity": "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==",
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
+ "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
- "@babel/helper-validator-identifier": "^7.27.1"
+ "@babel/helper-validator-identifier": "^7.28.5"
},
"engines": {
"node": ">=6.9.0"
@@ -1666,69 +1755,25 @@
"node": ">=18"
}
},
- "node_modules/@bufbuild/protobuf": {
- "version": "2.2.5",
- "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.2.5.tgz",
- "integrity": "sha512-/g5EzJifw5GF8aren8wZ/G5oMuPoGeS6MQD3ca8ddcvdXR5UELUfdTZITCGNhNXynY/AYl3Z4plmxdj/tRl/hQ==",
- "dev": true,
- "license": "(Apache-2.0 AND BSD-3-Clause)"
- },
- "node_modules/@bundled-es-modules/cookie": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/@bundled-es-modules/cookie/-/cookie-2.0.1.tgz",
- "integrity": "sha512-8o+5fRPLNbjbdGRRmJj3h6Hh1AQJf2dk3qQ/5ZFb+PXkRNiSoMGGUKlsgLfrxneb72axVJyIYji64E2+nNfYyw==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "cookie": "^0.7.2"
- }
- },
- "node_modules/@bundled-es-modules/cookie/node_modules/cookie": {
- "version": "0.7.2",
- "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
- "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "node_modules/@bramus/specificity": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
+ "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
"dev": true,
"license": "MIT",
- "engines": {
- "node": ">= 0.6"
- }
- },
- "node_modules/@bundled-es-modules/statuses": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/@bundled-es-modules/statuses/-/statuses-1.0.1.tgz",
- "integrity": "sha512-yn7BklA5acgcBr+7w064fGV+SGIFySjCKpqjcWgBAIfrAkY+4GQTJJHQMeT3V/sgz23VTEVV8TtOmkvJAhFVfg==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "statuses": "^2.0.1"
- }
- },
- "node_modules/@bundled-es-modules/tough-cookie": {
- "version": "0.1.6",
- "resolved": "https://registry.npmjs.org/@bundled-es-modules/tough-cookie/-/tough-cookie-0.1.6.tgz",
- "integrity": "sha512-dvMHbL464C0zI+Yqxbz6kZ5TOEp7GLW+pry/RWndAR8MJQAXZ2rPmIs8tziTZjeIyhSNZgZbCePtfSbdWqStJw==",
- "dev": true,
- "license": "ISC",
"dependencies": {
- "@types/tough-cookie": "^4.0.5",
- "tough-cookie": "^4.1.4"
+ "css-tree": "^3.0.0"
+ },
+ "bin": {
+ "specificity": "bin/cli.js"
}
},
- "node_modules/@bundled-es-modules/tough-cookie/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==",
+ "node_modules/@bufbuild/protobuf": {
+ "version": "2.11.0",
+ "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.11.0.tgz",
+ "integrity": "sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==",
"dev": true,
- "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"
- }
+ "license": "(Apache-2.0 AND BSD-3-Clause)"
},
"node_modules/@canvas/image-data": {
"version": "1.0.0",
@@ -1738,9 +1783,9 @@
"license": "MIT"
},
"node_modules/@csstools/color-helpers": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.0.1.tgz",
- "integrity": "sha512-MKtmkA0BX87PKaO1NFRTFH+UnkgnmySQOvNxJubsadusqPEC2aJ9MOQiMceZJJ6oitUl/i0L6u0M1IrmAOmgBA==",
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
+ "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==",
"dev": true,
"funding": [
{
@@ -1754,13 +1799,13 @@
],
"license": "MIT-0",
"engines": {
- "node": ">=18"
+ "node": ">=20.19.0"
}
},
"node_modules/@csstools/css-calc": {
- "version": "2.1.1",
- "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.1.tgz",
- "integrity": "sha512-rL7kaUnTkL9K+Cvo2pnCieqNpTKgQzy5f+N+5Iuko9HAoasP+xgprVh7KN/MaJVvVL1l0EzQq2MoqBHKSrDrag==",
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz",
+ "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==",
"dev": true,
"funding": [
{
@@ -1774,17 +1819,17 @@
],
"license": "MIT",
"engines": {
- "node": ">=18"
+ "node": ">=20.19.0"
},
"peerDependencies": {
- "@csstools/css-parser-algorithms": "^3.0.4",
- "@csstools/css-tokenizer": "^3.0.3"
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-color-parser": {
- "version": "3.0.7",
- "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.0.7.tgz",
- "integrity": "sha512-nkMp2mTICw32uE5NN+EsJ4f5N+IGFeCFu4bGpiKgb2Pq/7J/MpyLBeQ5ry4KKtRFZaYs6sTmcMYrSRIyj5DFKA==",
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz",
+ "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==",
"dev": true,
"funding": [
{
@@ -1798,21 +1843,21 @@
],
"license": "MIT",
"dependencies": {
- "@csstools/color-helpers": "^5.0.1",
- "@csstools/css-calc": "^2.1.1"
+ "@csstools/color-helpers": "^6.0.2",
+ "@csstools/css-calc": "^3.1.1"
},
"engines": {
- "node": ">=18"
+ "node": ">=20.19.0"
},
"peerDependencies": {
- "@csstools/css-parser-algorithms": "^3.0.4",
- "@csstools/css-tokenizer": "^3.0.3"
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-parser-algorithms": {
- "version": "3.0.4",
- "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.4.tgz",
- "integrity": "sha512-Up7rBoV77rv29d3uKHUIVubz1BTcgyUK72IvCQAbfbMv584xHcGKCKbWh7i8hPrRJ7qU4Y8IO3IY9m+iTB7P3A==",
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
+ "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
"dev": true,
"funding": [
{
@@ -1826,16 +1871,41 @@
],
"license": "MIT",
"engines": {
- "node": ">=18"
+ "node": ">=20.19.0"
},
"peerDependencies": {
- "@csstools/css-tokenizer": "^3.0.3"
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-syntax-patches-for-csstree": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.2.tgz",
+ "integrity": "sha512-5GkLzz4prTIpoyeUiIu3iV6CSG3Plo7xRVOFPKI7FVEJ3mZ0A8SwK0XU3Gl7xAkiQ+mDyam+NNp875/C5y+jSA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "peerDependencies": {
+ "css-tree": "^3.2.1"
+ },
+ "peerDependenciesMeta": {
+ "css-tree": {
+ "optional": true
+ }
}
},
"node_modules/@csstools/css-tokenizer": {
- "version": "3.0.3",
- "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.3.tgz",
- "integrity": "sha512-UJnjoFsmxfKUdNYdWgOB0mWUypuLvAfQPH1+pyvRJs6euowbFkFC6P13w1l8mJyi3vxYMxc9kld5jZEGRQs6bw==",
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
+ "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
"dev": true,
"funding": [
{
@@ -1849,13 +1919,13 @@
],
"license": "MIT",
"engines": {
- "node": ">=18"
+ "node": ">=20.19.0"
}
},
"node_modules/@emnapi/runtime": {
- "version": "1.5.0",
- "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.5.0.tgz",
- "integrity": "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==",
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz",
+ "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -1864,9 +1934,9 @@
}
},
"node_modules/@esbuild/aix-ppc64": {
- "version": "0.25.2",
- "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.2.tgz",
- "integrity": "sha512-wCIboOL2yXZym2cgm6mlA742s9QeJ8DjGVaL39dLN4rRwrOgOyYSnOaFPhKZGLb2ngj4EyfAFjsNJwPXZvseag==",
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
+ "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==",
"cpu": [
"ppc64"
],
@@ -1881,9 +1951,9 @@
}
},
"node_modules/@esbuild/android-arm": {
- "version": "0.25.2",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.2.tgz",
- "integrity": "sha512-NQhH7jFstVY5x8CKbcfa166GoV0EFkaPkCKBQkdPJFvo5u+nGXLEH/ooniLb3QI8Fk58YAx7nsPLozUWfCBOJA==",
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz",
+ "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==",
"cpu": [
"arm"
],
@@ -1898,9 +1968,9 @@
}
},
"node_modules/@esbuild/android-arm64": {
- "version": "0.25.2",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.2.tgz",
- "integrity": "sha512-5ZAX5xOmTligeBaeNEPnPaeEuah53Id2tX4c2CVP3JaROTH+j4fnfHCkr1PjXMd78hMst+TlkfKcW/DlTq0i4w==",
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz",
+ "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==",
"cpu": [
"arm64"
],
@@ -1915,9 +1985,9 @@
}
},
"node_modules/@esbuild/android-x64": {
- "version": "0.25.2",
- "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.2.tgz",
- "integrity": "sha512-Ffcx+nnma8Sge4jzddPHCZVRvIfQ0kMsUsCMcJRHkGJ1cDmhe4SsrYIjLUKn1xpHZybmOqCWwB0zQvsjdEHtkg==",
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz",
+ "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==",
"cpu": [
"x64"
],
@@ -1932,9 +2002,9 @@
}
},
"node_modules/@esbuild/darwin-arm64": {
- "version": "0.25.2",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.2.tgz",
- "integrity": "sha512-MpM6LUVTXAzOvN4KbjzU/q5smzryuoNjlriAIx+06RpecwCkL9JpenNzpKd2YMzLJFOdPqBpuub6eVRP5IgiSA==",
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz",
+ "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==",
"cpu": [
"arm64"
],
@@ -1949,9 +2019,9 @@
}
},
"node_modules/@esbuild/darwin-x64": {
- "version": "0.25.2",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.2.tgz",
- "integrity": "sha512-5eRPrTX7wFyuWe8FqEFPG2cU0+butQQVNcT4sVipqjLYQjjh8a8+vUTfgBKM88ObB85ahsnTwF7PSIt6PG+QkA==",
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz",
+ "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==",
"cpu": [
"x64"
],
@@ -1966,9 +2036,9 @@
}
},
"node_modules/@esbuild/freebsd-arm64": {
- "version": "0.25.2",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.2.tgz",
- "integrity": "sha512-mLwm4vXKiQ2UTSX4+ImyiPdiHjiZhIaE9QvC7sw0tZ6HoNMjYAqQpGyui5VRIi5sGd+uWq940gdCbY3VLvsO1w==",
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz",
+ "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==",
"cpu": [
"arm64"
],
@@ -1983,9 +2053,9 @@
}
},
"node_modules/@esbuild/freebsd-x64": {
- "version": "0.25.2",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.2.tgz",
- "integrity": "sha512-6qyyn6TjayJSwGpm8J9QYYGQcRgc90nmfdUb0O7pp1s4lTY+9D0H9O02v5JqGApUyiHOtkz6+1hZNvNtEhbwRQ==",
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz",
+ "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==",
"cpu": [
"x64"
],
@@ -2000,9 +2070,9 @@
}
},
"node_modules/@esbuild/linux-arm": {
- "version": "0.25.2",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.2.tgz",
- "integrity": "sha512-UHBRgJcmjJv5oeQF8EpTRZs/1knq6loLxTsjc3nxO9eXAPDLcWW55flrMVc97qFPbmZP31ta1AZVUKQzKTzb0g==",
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz",
+ "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==",
"cpu": [
"arm"
],
@@ -2017,9 +2087,9 @@
}
},
"node_modules/@esbuild/linux-arm64": {
- "version": "0.25.2",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.2.tgz",
- "integrity": "sha512-gq/sjLsOyMT19I8obBISvhoYiZIAaGF8JpeXu1u8yPv8BE5HlWYobmlsfijFIZ9hIVGYkbdFhEqC0NvM4kNO0g==",
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz",
+ "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==",
"cpu": [
"arm64"
],
@@ -2034,9 +2104,9 @@
}
},
"node_modules/@esbuild/linux-ia32": {
- "version": "0.25.2",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.2.tgz",
- "integrity": "sha512-bBYCv9obgW2cBP+2ZWfjYTU+f5cxRoGGQ5SeDbYdFCAZpYWrfjjfYwvUpP8MlKbP0nwZ5gyOU/0aUzZ5HWPuvQ==",
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz",
+ "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==",
"cpu": [
"ia32"
],
@@ -2051,9 +2121,9 @@
}
},
"node_modules/@esbuild/linux-loong64": {
- "version": "0.25.2",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.2.tgz",
- "integrity": "sha512-SHNGiKtvnU2dBlM5D8CXRFdd+6etgZ9dXfaPCeJtz+37PIUlixvlIhI23L5khKXs3DIzAn9V8v+qb1TRKrgT5w==",
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz",
+ "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==",
"cpu": [
"loong64"
],
@@ -2068,9 +2138,9 @@
}
},
"node_modules/@esbuild/linux-mips64el": {
- "version": "0.25.2",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.2.tgz",
- "integrity": "sha512-hDDRlzE6rPeoj+5fsADqdUZl1OzqDYow4TB4Y/3PlKBD0ph1e6uPHzIQcv2Z65u2K0kpeByIyAjCmjn1hJgG0Q==",
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz",
+ "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==",
"cpu": [
"mips64el"
],
@@ -2085,9 +2155,9 @@
}
},
"node_modules/@esbuild/linux-ppc64": {
- "version": "0.25.2",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.2.tgz",
- "integrity": "sha512-tsHu2RRSWzipmUi9UBDEzc0nLc4HtpZEI5Ba+Omms5456x5WaNuiG3u7xh5AO6sipnJ9r4cRWQB2tUjPyIkc6g==",
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz",
+ "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==",
"cpu": [
"ppc64"
],
@@ -2102,9 +2172,9 @@
}
},
"node_modules/@esbuild/linux-riscv64": {
- "version": "0.25.2",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.2.tgz",
- "integrity": "sha512-k4LtpgV7NJQOml/10uPU0s4SAXGnowi5qBSjaLWMojNCUICNu7TshqHLAEbkBdAszL5TabfvQ48kK84hyFzjnw==",
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz",
+ "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==",
"cpu": [
"riscv64"
],
@@ -2119,9 +2189,9 @@
}
},
"node_modules/@esbuild/linux-s390x": {
- "version": "0.25.2",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.2.tgz",
- "integrity": "sha512-GRa4IshOdvKY7M/rDpRR3gkiTNp34M0eLTaC1a08gNrh4u488aPhuZOCpkF6+2wl3zAN7L7XIpOFBhnaE3/Q8Q==",
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz",
+ "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==",
"cpu": [
"s390x"
],
@@ -2136,9 +2206,9 @@
}
},
"node_modules/@esbuild/linux-x64": {
- "version": "0.25.2",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.2.tgz",
- "integrity": "sha512-QInHERlqpTTZ4FRB0fROQWXcYRD64lAoiegezDunLpalZMjcUcld3YzZmVJ2H/Cp0wJRZ8Xtjtj0cEHhYc/uUg==",
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz",
+ "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==",
"cpu": [
"x64"
],
@@ -2153,9 +2223,9 @@
}
},
"node_modules/@esbuild/netbsd-arm64": {
- "version": "0.25.2",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.2.tgz",
- "integrity": "sha512-talAIBoY5M8vHc6EeI2WW9d/CkiO9MQJ0IOWX8hrLhxGbro/vBXJvaQXefW2cP0z0nQVTdQ/eNyGFV1GSKrxfw==",
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz",
+ "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==",
"cpu": [
"arm64"
],
@@ -2170,9 +2240,9 @@
}
},
"node_modules/@esbuild/netbsd-x64": {
- "version": "0.25.2",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.2.tgz",
- "integrity": "sha512-voZT9Z+tpOxrvfKFyfDYPc4DO4rk06qamv1a/fkuzHpiVBMOhpjK+vBmWM8J1eiB3OLSMFYNaOaBNLXGChf5tg==",
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz",
+ "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==",
"cpu": [
"x64"
],
@@ -2187,9 +2257,9 @@
}
},
"node_modules/@esbuild/openbsd-arm64": {
- "version": "0.25.2",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.2.tgz",
- "integrity": "sha512-dcXYOC6NXOqcykeDlwId9kB6OkPUxOEqU+rkrYVqJbK2hagWOMrsTGsMr8+rW02M+d5Op5NNlgMmjzecaRf7Tg==",
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz",
+ "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==",
"cpu": [
"arm64"
],
@@ -2204,9 +2274,9 @@
}
},
"node_modules/@esbuild/openbsd-x64": {
- "version": "0.25.2",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.2.tgz",
- "integrity": "sha512-t/TkWwahkH0Tsgoq1Ju7QfgGhArkGLkF1uYz8nQS/PPFlXbP5YgRpqQR3ARRiC2iXoLTWFxc6DJMSK10dVXluw==",
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz",
+ "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==",
"cpu": [
"x64"
],
@@ -2220,10 +2290,27 @@
"node": ">=18"
}
},
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz",
+ "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@esbuild/sunos-x64": {
- "version": "0.25.2",
- "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.2.tgz",
- "integrity": "sha512-cfZH1co2+imVdWCjd+D1gf9NjkchVhhdpgb1q5y6Hcv9TP6Zi9ZG/beI3ig8TvwT9lH9dlxLq5MQBBgwuj4xvA==",
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz",
+ "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==",
"cpu": [
"x64"
],
@@ -2238,9 +2325,9 @@
}
},
"node_modules/@esbuild/win32-arm64": {
- "version": "0.25.2",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.2.tgz",
- "integrity": "sha512-7Loyjh+D/Nx/sOTzV8vfbB3GJuHdOQyrOryFdZvPHLf42Tk9ivBU5Aedi7iyX+x6rbn2Mh68T4qq1SDqJBQO5Q==",
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz",
+ "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==",
"cpu": [
"arm64"
],
@@ -2255,9 +2342,9 @@
}
},
"node_modules/@esbuild/win32-ia32": {
- "version": "0.25.2",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.2.tgz",
- "integrity": "sha512-WRJgsz9un0nqZJ4MfhabxaD9Ft8KioqU3JMinOTvobbX6MOSUigSBlogP8QB3uxpJDsFS6yN+3FDBdqE5lg9kg==",
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz",
+ "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==",
"cpu": [
"ia32"
],
@@ -2272,9 +2359,9 @@
}
},
"node_modules/@esbuild/win32-x64": {
- "version": "0.25.2",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.2.tgz",
- "integrity": "sha512-kM3HKb16VIXZyIeVrM1ygYmZBKybX8N4p754bw390wGO3Tf2j4L2/WYL+4suWujpgf6GBYs3jv7TyUivdd05JA==",
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz",
+ "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==",
"cpu": [
"x64"
],
@@ -2289,86 +2376,136 @@
}
},
"node_modules/@eslint-community/eslint-utils": {
- "version": "4.4.0",
- "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz",
- "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==",
+ "version": "4.9.1",
+ "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
+ "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "eslint-visitor-keys": "^3.3.0"
+ "eslint-visitor-keys": "^3.4.3"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
},
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ },
"peerDependencies": {
"eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
}
},
"node_modules/@eslint-community/regexpp": {
- "version": "4.10.0",
- "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.10.0.tgz",
- "integrity": "sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==",
+ "version": "4.12.2",
+ "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
+ "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
"dev": true,
+ "license": "MIT",
"engines": {
"node": "^12.0.0 || ^14.0.0 || >=16.0.0"
}
},
- "node_modules/@eslint/eslintrc": {
- "version": "2.1.4",
- "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz",
- "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==",
+ "node_modules/@eslint/config-array": {
+ "version": "0.23.3",
+ "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.3.tgz",
+ "integrity": "sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==",
"dev": true,
+ "license": "Apache-2.0",
"dependencies": {
- "ajv": "^6.12.4",
- "debug": "^4.3.2",
- "espree": "^9.6.0",
- "globals": "^13.19.0",
- "ignore": "^5.2.0",
- "import-fresh": "^3.2.1",
- "js-yaml": "^4.1.0",
- "minimatch": "^3.1.2",
- "strip-json-comments": "^3.1.1"
+ "@eslint/object-schema": "^3.0.3",
+ "debug": "^4.3.1",
+ "minimatch": "^10.2.4"
},
"engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/eslint"
+ "node": "^20.19.0 || ^22.13.0 || >=24"
}
},
- "node_modules/@eslint/eslintrc/node_modules/globals": {
- "version": "13.24.0",
- "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz",
- "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==",
+ "node_modules/@eslint/config-helpers": {
+ "version": "0.5.3",
+ "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.3.tgz",
+ "integrity": "sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw==",
"dev": true,
+ "license": "Apache-2.0",
"dependencies": {
- "type-fest": "^0.20.2"
+ "@eslint/core": "^1.1.1"
},
"engines": {
- "node": ">=8"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
+ "node": "^20.19.0 || ^22.13.0 || >=24"
}
},
- "node_modules/@eslint/eslintrc/node_modules/type-fest": {
- "version": "0.20.2",
- "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
- "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
+ "node_modules/@eslint/core": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.1.tgz",
+ "integrity": "sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==",
"dev": true,
- "engines": {
- "node": ">=10"
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@types/json-schema": "^7.0.15"
},
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
}
},
"node_modules/@eslint/js": {
- "version": "8.57.0",
- "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz",
- "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==",
+ "version": "10.0.1",
+ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz",
+ "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==",
"dev": true,
+ "license": "MIT",
"engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
+ "funding": {
+ "url": "https://eslint.org/donate"
+ },
+ "peerDependencies": {
+ "eslint": "^10.0.0"
+ },
+ "peerDependenciesMeta": {
+ "eslint": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@eslint/object-schema": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.3.tgz",
+ "integrity": "sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ }
+ },
+ "node_modules/@eslint/plugin-kit": {
+ "version": "0.6.1",
+ "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.1.tgz",
+ "integrity": "sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/core": "^1.1.1",
+ "levn": "^0.4.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ }
+ },
+ "node_modules/@exodus/bytes": {
+ "version": "1.15.0",
+ "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz",
+ "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ },
+ "peerDependencies": {
+ "@noble/hashes": "^1.8.0 || ^2.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@noble/hashes": {
+ "optional": true
+ }
}
},
"node_modules/@floating-ui/core": {
@@ -2424,16 +2561,19 @@
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
"license": "MIT"
},
- "node_modules/@fontsource/roboto": {
- "version": "5.0.12",
- "resolved": "https://registry.npmjs.org/@fontsource/roboto/-/roboto-5.0.12.tgz",
- "integrity": "sha512-x0o17jvgoSSbS9OZnUX2+xJmVRvVCfeaYJjkS7w62iN7CuJWtMf5vJj8LqgC7ibqIkitOHVW+XssRjgrcHn62g==",
- "dev": true
+ "node_modules/@fontsource-variable/geist": {
+ "version": "5.2.8",
+ "resolved": "https://registry.npmjs.org/@fontsource-variable/geist/-/geist-5.2.8.tgz",
+ "integrity": "sha512-cJ6m9e+8MQ5dCYJsLylfZrgBh6KkG4bOLckB35Tr9J/EqdkEM6QllH5PxqP1dhTvFup+HtMRPuz9xOjxXJggxw==",
+ "license": "OFL-1.1",
+ "funding": {
+ "url": "https://github.com/sponsors/ayuhito"
+ }
},
"node_modules/@fortawesome/fontawesome-common-types": {
- "version": "7.1.0",
- "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-7.1.0.tgz",
- "integrity": "sha512-l/BQM7fYntsCI//du+6sEnHOP6a74UixFyOYUyz2DLMXKx+6DEhfR3F2NYGE45XH1JJuIamacb4IZs9S0ZOWLA==",
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-7.2.0.tgz",
+ "integrity": "sha512-IpR0bER9FY25p+e7BmFH25MZKEwFHTfRAfhOyJubgiDnoJNsSvJ7nigLraHtp4VOG/cy8D7uiV0dLkHOne5Fhw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2441,61 +2581,61 @@
}
},
"node_modules/@fortawesome/fontawesome-svg-core": {
- "version": "7.1.0",
- "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-7.1.0.tgz",
- "integrity": "sha512-fNxRUk1KhjSbnbuBxlWSnBLKLBNun52ZBTcs22H/xEEzM6Ap81ZFTQ4bZBxVQGQgVY0xugKGoRcCbaKjLQ3XZA==",
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-7.2.0.tgz",
+ "integrity": "sha512-6639htZMjEkwskf3J+e6/iar+4cTNM9qhoWuRfj9F3eJD6r7iCzV1SWnQr2Mdv0QT0suuqU8BoJCZUyCtP9R4Q==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@fortawesome/fontawesome-common-types": "7.1.0"
+ "@fortawesome/fontawesome-common-types": "7.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-brands-svg-icons": {
- "version": "7.1.0",
- "resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-7.1.0.tgz",
- "integrity": "sha512-9byUd9bgNfthsZAjBl6GxOu1VPHgBuRUP9juI7ZoM98h8xNPTCTagfwUFyYscdZq4Hr7gD1azMfM9s5tIWKZZA==",
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-7.2.0.tgz",
+ "integrity": "sha512-VNG8xqOip1JuJcC3zsVsKRQ60oXG9+oYNDCosjoU/H9pgYmLTEwWw8pE0jhPz/JWdHeUuK6+NQ3qsM4gIbdbYQ==",
"dev": true,
"license": "(CC-BY-4.0 AND MIT)",
"dependencies": {
- "@fortawesome/fontawesome-common-types": "7.1.0"
+ "@fortawesome/fontawesome-common-types": "7.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-regular-svg-icons": {
- "version": "7.1.0",
- "resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-7.1.0.tgz",
- "integrity": "sha512-0e2fdEyB4AR+e6kU4yxwA/MonnYcw/CsMEP9lH82ORFi9svA6/RhDyhxIv5mlJaldmaHLLYVTb+3iEr+PDSZuQ==",
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-7.2.0.tgz",
+ "integrity": "sha512-iycmlN51EULlQ4D/UU9WZnHiN0CvjJ2TuuCrAh+1MVdzD+4ViKYH2deNAll4XAAYlZa8WAefHR5taSK8hYmSMw==",
"dev": true,
"license": "(CC-BY-4.0 AND MIT)",
"dependencies": {
- "@fortawesome/fontawesome-common-types": "7.1.0"
+ "@fortawesome/fontawesome-common-types": "7.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-solid-svg-icons": {
- "version": "7.1.0",
- "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-7.1.0.tgz",
- "integrity": "sha512-Udu3K7SzAo9N013qt7qmm22/wo2hADdheXtBfxFTecp+ogsc0caQNRKEb7pkvvagUGOpG9wJC1ViH6WXs8oXIA==",
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-7.2.0.tgz",
+ "integrity": "sha512-YTVITFGN0/24PxzXrwqCgnyd7njDuzp5ZvaCx5nq/jg55kUYd94Nj8UTchBdBofi/L0nwRfjGOg0E41d2u9T1w==",
"dev": true,
"license": "(CC-BY-4.0 AND MIT)",
"dependencies": {
- "@fortawesome/fontawesome-common-types": "7.1.0"
+ "@fortawesome/fontawesome-common-types": "7.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/react-fontawesome": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-3.1.0.tgz",
- "integrity": "sha512-5OUQH9aDH/xHJwnpD4J7oEdGvFGJgYnGe0UebaPIdMW9UxYC/f5jv2VjVEgnikdJN0HL8yQxp9Nq+7gqGZpIIA==",
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-3.3.0.tgz",
+ "integrity": "sha512-EHmHeTf8WgO29sdY3iX/7ekE3gNUdlc2RW6mm/FzELlHFKfTrA9S4MlyquRR+RRCRCn8+jXfLFpLGB2l7wCWyw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2506,18 +2646,28 @@
"react": "^18.0.0 || ^19.0.0"
}
},
- "node_modules/@humanwhocodes/config-array": {
- "version": "0.11.14",
- "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz",
- "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==",
+ "node_modules/@humanfs/core": {
+ "version": "0.19.1",
+ "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
+ "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanfs/node": {
+ "version": "0.16.7",
+ "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz",
+ "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==",
"dev": true,
+ "license": "Apache-2.0",
"dependencies": {
- "@humanwhocodes/object-schema": "^2.0.2",
- "debug": "^4.3.1",
- "minimatch": "^3.0.5"
+ "@humanfs/core": "^0.19.1",
+ "@humanwhocodes/retry": "^0.4.0"
},
"engines": {
- "node": ">=10.10.0"
+ "node": ">=18.18.0"
}
},
"node_modules/@humanwhocodes/module-importer": {
@@ -2533,11 +2683,19 @@
"url": "https://github.com/sponsors/nzakas"
}
},
- "node_modules/@humanwhocodes/object-schema": {
- "version": "2.0.3",
- "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz",
- "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==",
- "dev": true
+ "node_modules/@humanwhocodes/retry": {
+ "version": "0.4.3",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
+ "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.33.5",
@@ -2993,158 +3151,83 @@
"@types/node": ">=18"
}
},
- "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.1.0",
- "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz",
- "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==",
- "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.1",
- "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
- "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
- "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==",
+ "node_modules/@jest/diff-sequences": {
+ "version": "30.3.0",
+ "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.3.0.tgz",
+ "integrity": "sha512-cG51MVnLq1ecVUaQ3fr6YuuAOitHK1S4WUJHnsPFE/quQr33ADUx1FfrTCpMCRxvy0Yr9BThKpDjSlcTi91tMA==",
"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": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
- "node_modules/@isaacs/cliui/node_modules/strip-ansi": {
- "version": "7.1.0",
- "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
- "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
+ "node_modules/@jest/expect-utils": {
+ "version": "30.3.0",
+ "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.3.0.tgz",
+ "integrity": "sha512-j0+W5iQQ8hBh7tHZkTQv3q2Fh/M7Je72cIsYqC4OaktgtO7v1So9UTjp6uPBHIaB6beoF/RRsCgMJKvti0wADA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "ansi-regex": "^6.0.1"
+ "@jest/get-type": "30.1.0"
},
"engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/chalk/strip-ansi?sponsor=1"
+ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
- "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==",
+ "node_modules/@jest/get-type": {
+ "version": "30.1.0",
+ "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz",
+ "integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==",
"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": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
- "node_modules/@istanbuljs/schema": {
- "version": "0.1.3",
- "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz",
- "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==",
+ "node_modules/@jest/pattern": {
+ "version": "30.0.1",
+ "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.0.1.tgz",
+ "integrity": "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==",
"dev": true,
"license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/@jest/expect-utils": {
- "version": "29.7.0",
- "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz",
- "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==",
- "dev": true,
"dependencies": {
- "jest-get-type": "^29.6.3"
+ "@types/node": "*",
+ "jest-regex-util": "30.0.1"
},
"engines": {
- "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/@jest/schemas": {
- "version": "29.6.3",
- "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz",
- "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==",
+ "version": "30.0.5",
+ "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz",
+ "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@sinclair/typebox": "^0.27.8"
+ "@sinclair/typebox": "^0.34.0"
},
"engines": {
- "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/@jest/types": {
- "version": "29.6.3",
- "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz",
- "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==",
+ "version": "30.3.0",
+ "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.3.0.tgz",
+ "integrity": "sha512-JHm87k7bA33hpBngtU8h6UBub/fqqA9uXfw+21j5Hmk7ooPHlboRNxHq0JcMtC+n8VJGP1mcfnD3Mk+XKe1oSw==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@jest/schemas": "^29.6.3",
- "@types/istanbul-lib-coverage": "^2.0.0",
- "@types/istanbul-reports": "^3.0.0",
+ "@jest/pattern": "30.0.1",
+ "@jest/schemas": "30.0.5",
+ "@types/istanbul-lib-coverage": "^2.0.6",
+ "@types/istanbul-reports": "^3.0.4",
"@types/node": "*",
- "@types/yargs": "^17.0.8",
- "chalk": "^4.0.0"
+ "@types/yargs": "^17.0.33",
+ "chalk": "^4.1.2"
},
"engines": {
- "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/@jridgewell/gen-mapping": {
@@ -3158,6 +3241,17 @@
"@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",
@@ -3179,16 +3273,16 @@
}
},
"node_modules/@jridgewell/sourcemap-codec": {
- "version": "1.5.0",
- "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz",
- "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==",
+ "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.30",
- "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.30.tgz",
- "integrity": "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==",
+ "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": {
@@ -3197,9 +3291,9 @@
}
},
"node_modules/@mantine/core": {
- "version": "8.3.9",
- "resolved": "https://registry.npmjs.org/@mantine/core/-/core-8.3.9.tgz",
- "integrity": "sha512-ivj0Crn5N521cI2eWZBsBGckg0ZYRqfOJz5vbbvYmfj65bp0EdsyqZuOxXzIcn2aUScQhskfvzyhV5XIUv81PQ==",
+ "version": "8.3.18",
+ "resolved": "https://registry.npmjs.org/@mantine/core/-/core-8.3.18.tgz",
+ "integrity": "sha512-9tph1lTVogKPjTx02eUxDUOdXacPzK62UuSqb4TdGliI54/Xgxftq0Dfqu6XuhCxn9J5MDJaNiLDvL/1KRkYqA==",
"license": "MIT",
"dependencies": {
"@floating-ui/react": "^0.27.16",
@@ -3210,7 +3304,7 @@
"type-fest": "^4.41.0"
},
"peerDependencies": {
- "@mantine/hooks": "8.3.9",
+ "@mantine/hooks": "8.3.18",
"react": "^18.x || ^19.x",
"react-dom": "^18.x || ^19.x"
}
@@ -3228,24 +3322,24 @@
}
},
"node_modules/@mantine/dropzone": {
- "version": "8.3.9",
- "resolved": "https://registry.npmjs.org/@mantine/dropzone/-/dropzone-8.3.9.tgz",
- "integrity": "sha512-RXFpmHQnANMxa3qBssQyI/YS+Xzpr6N/vPWTXrAzjNrJApbwShlIKRDHNw4mmydYT3Wq9y86uzXFPsrPoaJWLA==",
+ "version": "8.3.18",
+ "resolved": "https://registry.npmjs.org/@mantine/dropzone/-/dropzone-8.3.18.tgz",
+ "integrity": "sha512-GaYUUl/382R7hl1g6heTCZ5a6T5x6qYPg0oID6ik/J0j7e5+XMZyTH5ITpaqpsBQ09GKKsF5y3iNehpSby8Kew==",
"license": "MIT",
"dependencies": {
- "react-dropzone": "14.3.8"
+ "react-dropzone": "15.0.0"
},
"peerDependencies": {
- "@mantine/core": "8.3.9",
- "@mantine/hooks": "8.3.9",
+ "@mantine/core": "8.3.18",
+ "@mantine/hooks": "8.3.18",
"react": "^18.x || ^19.x",
"react-dom": "^18.x || ^19.x"
}
},
"node_modules/@mantine/form": {
- "version": "8.3.9",
- "resolved": "https://registry.npmjs.org/@mantine/form/-/form-8.3.9.tgz",
- "integrity": "sha512-G7eCo5TEWGcsWHrNGwp/o1yPcCYbaMdLq6SClN+wMhAA+qaEO1ZHf9mX8fSOX78nkaoyIrZHGOhbVXcZ7hmVrQ==",
+ "version": "8.3.18",
+ "resolved": "https://registry.npmjs.org/@mantine/form/-/form-8.3.18.tgz",
+ "integrity": "sha512-r5OGLJWTkmIruFjRZRZy9oA7maNYlyt50jB4Pmd2X5360WOmJLd4KH8MFhHZQC7vN+z8/rmBl3t3XGAR2I8xig==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
@@ -3256,55 +3350,55 @@
}
},
"node_modules/@mantine/hooks": {
- "version": "8.3.9",
- "resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.3.9.tgz",
- "integrity": "sha512-Dfz7W0+K1cq4Gb1WFQCZn8tsMXkLH6MV409wZR/ToqsxdNDUMJ/xxbfnwEXWEZjXNJd1wDETHgc+cZG2lTe3Xw==",
+ "version": "8.3.18",
+ "resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.3.18.tgz",
+ "integrity": "sha512-QoWr9+S8gg5050TQ06aTSxtlpGjYOpIllRbjYYXlRvZeTsUqiTbVfvQROLexu4rEaK+yy9Wwriwl9PMRgbLqPw==",
"license": "MIT",
"peerDependencies": {
"react": "^18.x || ^19.x"
}
},
"node_modules/@mantine/modals": {
- "version": "8.3.9",
- "resolved": "https://registry.npmjs.org/@mantine/modals/-/modals-8.3.9.tgz",
- "integrity": "sha512-0WOikHgECJeWA/1TNf+sxOnpNwQjmpyph3XEhzFkgneimW6Ry7R6qd/i345CDLSu6kP6FGGRI73SUROiTcu2Ng==",
+ "version": "8.3.18",
+ "resolved": "https://registry.npmjs.org/@mantine/modals/-/modals-8.3.18.tgz",
+ "integrity": "sha512-JfPDS4549L314SxFPC1x6CbKwzh82OdnIzwgMxPCVNsWLKV2vEHHUH/fzUYj4Wli6IBrsW4cufjMj9BTj3hm3Q==",
"license": "MIT",
"peerDependencies": {
- "@mantine/core": "8.3.9",
- "@mantine/hooks": "8.3.9",
+ "@mantine/core": "8.3.18",
+ "@mantine/hooks": "8.3.18",
"react": "^18.x || ^19.x",
"react-dom": "^18.x || ^19.x"
}
},
"node_modules/@mantine/notifications": {
- "version": "8.3.9",
- "resolved": "https://registry.npmjs.org/@mantine/notifications/-/notifications-8.3.9.tgz",
- "integrity": "sha512-emUdoCyaccf/NuNmJ4fQgloJ7hEod0Pde7XIoD9xUUztVchL143oWRU2gYm6cwqzSyjpjTaqPXfz5UvEBRYjZw==",
+ "version": "8.3.18",
+ "resolved": "https://registry.npmjs.org/@mantine/notifications/-/notifications-8.3.18.tgz",
+ "integrity": "sha512-IpQ0lmwbigTBbZCR6iSYWqIOKEx1tlcd7PcEJ5M5X1qeVSY/N3mmDQt1eJmObvcyDeL5cTJMbSA9UPqhRqo9jw==",
"license": "MIT",
"dependencies": {
- "@mantine/store": "8.3.9",
+ "@mantine/store": "8.3.18",
"react-transition-group": "4.4.5"
},
"peerDependencies": {
- "@mantine/core": "8.3.9",
- "@mantine/hooks": "8.3.9",
+ "@mantine/core": "8.3.18",
+ "@mantine/hooks": "8.3.18",
"react": "^18.x || ^19.x",
"react-dom": "^18.x || ^19.x"
}
},
"node_modules/@mantine/store": {
- "version": "8.3.9",
- "resolved": "https://registry.npmjs.org/@mantine/store/-/store-8.3.9.tgz",
- "integrity": "sha512-Z4tYW597mD3NxHLlJ3OJ1aKucmwrD9nhqobz+142JNw01aHqzKjxVXlu3L5GGa7F3u3OjXJk/qb1QmUs4sU+Jw==",
+ "version": "8.3.18",
+ "resolved": "https://registry.npmjs.org/@mantine/store/-/store-8.3.18.tgz",
+ "integrity": "sha512-i+QRTLmZzLldea0egtUVnGALd6UMIu8jd44nrNWBSNIXJU/8B6rMlC6gyX+l4szopZSuOaaNJIXkqRdC1gQsVg==",
"license": "MIT",
"peerDependencies": {
"react": "^18.x || ^19.x"
}
},
"node_modules/@mswjs/interceptors": {
- "version": "0.37.5",
- "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.37.5.tgz",
- "integrity": "sha512-AAwRb5vXFcY4L+FvZ7LZusDuZ0vEe0Zm8ohn1FM6/X7A3bj4mqmkAcGRWuvC2JwSygNwHAAmMnAI73vPHeqsHA==",
+ "version": "0.41.3",
+ "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.3.tgz",
+ "integrity": "sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3319,41 +3413,6 @@
"node": ">=18"
}
},
- "node_modules/@nodelib/fs.scandir": {
- "version": "2.1.5",
- "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
- "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
- "dev": true,
- "dependencies": {
- "@nodelib/fs.stat": "2.0.5",
- "run-parallel": "^1.1.9"
- },
- "engines": {
- "node": ">= 8"
- }
- },
- "node_modules/@nodelib/fs.stat": {
- "version": "2.0.5",
- "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
- "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
- "dev": true,
- "engines": {
- "node": ">= 8"
- }
- },
- "node_modules/@nodelib/fs.walk": {
- "version": "1.2.8",
- "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
- "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
- "dev": true,
- "dependencies": {
- "@nodelib/fs.scandir": "2.1.5",
- "fastq": "^1.6.0"
- },
- "engines": {
- "node": ">= 8"
- }
- },
"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",
@@ -3379,99 +3438,463 @@
"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==",
+ "node_modules/@parcel/watcher": {
+ "version": "2.5.6",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz",
+ "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==",
"dev": true,
+ "hasInstallScript": true,
"license": "MIT",
"optional": true,
+ "dependencies": {
+ "detect-libc": "^2.0.3",
+ "is-glob": "^4.0.3",
+ "node-addon-api": "^7.0.0",
+ "picomatch": "^4.0.3"
+ },
"engines": {
- "node": ">=14"
- }
- },
- "node_modules/@polka/url": {
- "version": "1.0.0-next.29",
- "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
- "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@quansync/fs": {
- "version": "0.1.2",
- "resolved": "https://registry.npmjs.org/@quansync/fs/-/fs-0.1.2.tgz",
- "integrity": "sha512-ezIadUb1aFhwJLd++WVqVpi9rnlX8vnd4ju7saPhwLHJN1mJgOv0puePTGV+FbtSnWtwoHDT8lAm4kagDZmpCg==",
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "@parcel/watcher-android-arm64": "2.5.6",
+ "@parcel/watcher-darwin-arm64": "2.5.6",
+ "@parcel/watcher-darwin-x64": "2.5.6",
+ "@parcel/watcher-freebsd-x64": "2.5.6",
+ "@parcel/watcher-linux-arm-glibc": "2.5.6",
+ "@parcel/watcher-linux-arm-musl": "2.5.6",
+ "@parcel/watcher-linux-arm64-glibc": "2.5.6",
+ "@parcel/watcher-linux-arm64-musl": "2.5.6",
+ "@parcel/watcher-linux-x64-glibc": "2.5.6",
+ "@parcel/watcher-linux-x64-musl": "2.5.6",
+ "@parcel/watcher-win32-arm64": "2.5.6",
+ "@parcel/watcher-win32-ia32": "2.5.6",
+ "@parcel/watcher-win32-x64": "2.5.6"
+ }
+ },
+ "node_modules/@parcel/watcher-android-arm64": {
+ "version": "2.5.6",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz",
+ "integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==",
+ "cpu": [
+ "arm64"
+ ],
"dev": true,
"license": "MIT",
- "dependencies": {
- "quansync": "^0.2.10"
- },
+ "optional": true,
+ "os": [
+ "android"
+ ],
"engines": {
- "node": ">=20.0.0"
+ "node": ">= 10.0.0"
},
"funding": {
- "url": "https://github.com/sponsors/sxzz"
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
}
},
- "node_modules/@rolldown/pluginutils": {
- "version": "1.0.0-beta.32",
- "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.32.tgz",
- "integrity": "sha512-QReCdvxiUZAPkvp1xpAg62IeNzykOFA6syH2CnClif4YmALN1XKpB39XneL80008UbtMShthSVDKmrx05N1q/g==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@rollup/plugin-node-resolve": {
- "version": "15.3.1",
- "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.3.1.tgz",
- "integrity": "sha512-tgg6b91pAybXHJQMAAwW9VuWBO6Thi+q7BCNARLwSqlmsHz0XYURtGvh/AuwSADXSI4h/2uHbs7s4FzlZDGSGA==",
+ "node_modules/@parcel/watcher-darwin-arm64": {
+ "version": "2.5.6",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz",
+ "integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==",
+ "cpu": [
+ "arm64"
+ ],
"dev": true,
"license": "MIT",
- "dependencies": {
- "@rollup/pluginutils": "^5.0.1",
- "@types/resolve": "1.20.2",
- "deepmerge": "^4.2.2",
- "is-module": "^1.0.0",
- "resolve": "^1.22.1"
- },
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
"engines": {
- "node": ">=14.0.0"
- },
- "peerDependencies": {
- "rollup": "^2.78.0||^3.0.0||^4.0.0"
+ "node": ">= 10.0.0"
},
- "peerDependenciesMeta": {
- "rollup": {
- "optional": true
- }
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
}
},
- "node_modules/@rollup/plugin-terser": {
- "version": "0.4.4",
- "resolved": "https://registry.npmjs.org/@rollup/plugin-terser/-/plugin-terser-0.4.4.tgz",
- "integrity": "sha512-XHeJC5Bgvs8LfukDwWZp7yeqin6ns8RTl2B9avbejt6tZqsqvVoWI7ZTQrcNsfKEDWBTnTxM8nMDkO2IFFbd0A==",
+ "node_modules/@parcel/watcher-darwin-x64": {
+ "version": "2.5.6",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz",
+ "integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==",
+ "cpu": [
+ "x64"
+ ],
"dev": true,
"license": "MIT",
- "dependencies": {
- "serialize-javascript": "^6.0.1",
- "smob": "^1.0.0",
- "terser": "^5.17.4"
- },
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
"engines": {
- "node": ">=14.0.0"
- },
- "peerDependencies": {
- "rollup": "^2.0.0||^3.0.0||^4.0.0"
+ "node": ">= 10.0.0"
},
- "peerDependenciesMeta": {
- "rollup": {
- "optional": true
- }
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
}
},
- "node_modules/@rollup/pluginutils": {
- "version": "5.1.4",
- "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.4.tgz",
- "integrity": "sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ==",
+ "node_modules/@parcel/watcher-freebsd-x64": {
+ "version": "2.5.6",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz",
+ "integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-linux-arm-glibc": {
+ "version": "2.5.6",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz",
+ "integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-linux-arm-musl": {
+ "version": "2.5.6",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz",
+ "integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-linux-arm64-glibc": {
+ "version": "2.5.6",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz",
+ "integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-linux-arm64-musl": {
+ "version": "2.5.6",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz",
+ "integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-linux-x64-glibc": {
+ "version": "2.5.6",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz",
+ "integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-linux-x64-musl": {
+ "version": "2.5.6",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz",
+ "integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-win32-arm64": {
+ "version": "2.5.6",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz",
+ "integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-win32-ia32": {
+ "version": "2.5.6",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz",
+ "integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-win32-x64": {
+ "version": "2.5.6",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz",
+ "integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher/node_modules/picomatch": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/@pkgr/core": {
+ "version": "0.2.9",
+ "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz",
+ "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.20.0 || ^14.18.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/pkgr"
+ }
+ },
+ "node_modules/@polka/url": {
+ "version": "1.0.0-next.29",
+ "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
+ "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@quansync/fs": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/@quansync/fs/-/fs-0.1.2.tgz",
+ "integrity": "sha512-ezIadUb1aFhwJLd++WVqVpi9rnlX8vnd4ju7saPhwLHJN1mJgOv0puePTGV+FbtSnWtwoHDT8lAm4kagDZmpCg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "quansync": "^0.2.10"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sxzz"
+ }
+ },
+ "node_modules/@reduxjs/toolkit": {
+ "version": "2.11.2",
+ "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
+ "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.0.0",
+ "@standard-schema/utils": "^0.3.0",
+ "immer": "^11.0.0",
+ "redux": "^5.0.1",
+ "redux-thunk": "^3.1.0",
+ "reselect": "^5.1.0"
+ },
+ "peerDependencies": {
+ "react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
+ "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
+ },
+ "peerDependenciesMeta": {
+ "react": {
+ "optional": true
+ },
+ "react-redux": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@reduxjs/toolkit/node_modules/immer": {
+ "version": "11.1.4",
+ "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
+ "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/immer"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-rc.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
+ "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@rollup/plugin-node-resolve": {
+ "version": "15.3.1",
+ "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.3.1.tgz",
+ "integrity": "sha512-tgg6b91pAybXHJQMAAwW9VuWBO6Thi+q7BCNARLwSqlmsHz0XYURtGvh/AuwSADXSI4h/2uHbs7s4FzlZDGSGA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@rollup/pluginutils": "^5.0.1",
+ "@types/resolve": "1.20.2",
+ "deepmerge": "^4.2.2",
+ "is-module": "^1.0.0",
+ "resolve": "^1.22.1"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "rollup": "^2.78.0||^3.0.0||^4.0.0"
+ },
+ "peerDependenciesMeta": {
+ "rollup": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@rollup/plugin-terser": {
+ "version": "0.4.4",
+ "resolved": "https://registry.npmjs.org/@rollup/plugin-terser/-/plugin-terser-0.4.4.tgz",
+ "integrity": "sha512-XHeJC5Bgvs8LfukDwWZp7yeqin6ns8RTl2B9avbejt6tZqsqvVoWI7ZTQrcNsfKEDWBTnTxM8nMDkO2IFFbd0A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "serialize-javascript": "^6.0.1",
+ "smob": "^1.0.0",
+ "terser": "^5.17.4"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "rollup": "^2.0.0||^3.0.0||^4.0.0"
+ },
+ "peerDependenciesMeta": {
+ "rollup": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@rollup/pluginutils": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz",
+ "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3499,9 +3922,9 @@
"license": "MIT"
},
"node_modules/@rollup/pluginutils/node_modules/picomatch": {
- "version": "4.0.2",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
- "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"engines": {
@@ -3512,9 +3935,9 @@
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
- "version": "4.48.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.48.1.tgz",
- "integrity": "sha512-rGmb8qoG/zdmKoYELCBwu7vt+9HxZ7Koos3pD0+sH5fR3u3Wb/jGcpnqxcnWsPEKDUyzeLSqksN8LJtgXjqBYw==",
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
+ "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
"cpu": [
"arm"
],
@@ -3526,9 +3949,9 @@
]
},
"node_modules/@rollup/rollup-android-arm64": {
- "version": "4.48.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.48.1.tgz",
- "integrity": "sha512-4e9WtTxrk3gu1DFE+imNJr4WsL13nWbD/Y6wQcyku5qadlKHY3OQ3LJ/INrrjngv2BJIHnIzbqMk1GTAC2P8yQ==",
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
+ "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
"cpu": [
"arm64"
],
@@ -3540,9 +3963,9 @@
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
- "version": "4.48.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.48.1.tgz",
- "integrity": "sha512-+XjmyChHfc4TSs6WUQGmVf7Hkg8ferMAE2aNYYWjiLzAS/T62uOsdfnqv+GHRjq7rKRnYh4mwWb4Hz7h/alp8A==",
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
+ "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
"cpu": [
"arm64"
],
@@ -3554,9 +3977,9 @@
]
},
"node_modules/@rollup/rollup-darwin-x64": {
- "version": "4.48.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.48.1.tgz",
- "integrity": "sha512-upGEY7Ftw8M6BAJyGwnwMw91rSqXTcOKZnnveKrVWsMTF8/k5mleKSuh7D4v4IV1pLxKAk3Tbs0Lo9qYmii5mQ==",
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
+ "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
"cpu": [
"x64"
],
@@ -3568,9 +3991,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
- "version": "4.48.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.48.1.tgz",
- "integrity": "sha512-P9ViWakdoynYFUOZhqq97vBrhuvRLAbN/p2tAVJvhLb8SvN7rbBnJQcBu8e/rQts42pXGLVhfsAP0k9KXWa3nQ==",
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
+ "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
"cpu": [
"arm64"
],
@@ -3582,9 +4005,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
- "version": "4.48.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.48.1.tgz",
- "integrity": "sha512-VLKIwIpnBya5/saccM8JshpbxfyJt0Dsli0PjXozHwbSVaHTvWXJH1bbCwPXxnMzU4zVEfgD1HpW3VQHomi2AQ==",
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
+ "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
"cpu": [
"x64"
],
@@ -3596,9 +4019,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
- "version": "4.48.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.48.1.tgz",
- "integrity": "sha512-3zEuZsXfKaw8n/yF7t8N6NNdhyFw3s8xJTqjbTDXlipwrEHo4GtIKcMJr5Ed29leLpB9AugtAQpAHW0jvtKKaQ==",
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
+ "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
"cpu": [
"arm"
],
@@ -3610,9 +4033,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
- "version": "4.48.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.48.1.tgz",
- "integrity": "sha512-leo9tOIlKrcBmmEypzunV/2w946JeLbTdDlwEZ7OnnsUyelZ72NMnT4B2vsikSgwQifjnJUbdXzuW4ToN1wV+Q==",
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
+ "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
"cpu": [
"arm"
],
@@ -3624,9 +4047,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
- "version": "4.48.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.48.1.tgz",
- "integrity": "sha512-Vy/WS4z4jEyvnJm+CnPfExIv5sSKqZrUr98h03hpAMbE2aI0aD2wvK6GiSe8Gx2wGp3eD81cYDpLLBqNb2ydwQ==",
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
+ "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
"cpu": [
"arm64"
],
@@ -3638,9 +4061,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
- "version": "4.48.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.48.1.tgz",
- "integrity": "sha512-x5Kzn7XTwIssU9UYqWDB9VpLpfHYuXw5c6bJr4Mzv9kIv242vmJHbI5PJJEnmBYitUIfoMCODDhR7KoZLot2VQ==",
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
+ "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
"cpu": [
"arm64"
],
@@ -3651,10 +4074,24 @@
"linux"
]
},
- "node_modules/@rollup/rollup-linux-loongarch64-gnu": {
- "version": "4.48.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.48.1.tgz",
- "integrity": "sha512-yzCaBbwkkWt/EcgJOKDUdUpMHjhiZT/eDktOPWvSRpqrVE04p0Nd6EGV4/g7MARXXeOqstflqsKuXVM3H9wOIQ==",
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
+ "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
+ "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
"cpu": [
"loong64"
],
@@ -3666,9 +4103,23 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
- "version": "4.48.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.48.1.tgz",
- "integrity": "sha512-UK0WzWUjMAJccHIeOpPhPcKBqax7QFg47hwZTp6kiMhQHeOYJeaMwzeRZe1q5IiTKsaLnHu9s6toSYVUlZ2QtQ==",
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
+ "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
+ "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
"cpu": [
"ppc64"
],
@@ -3680,9 +4131,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
- "version": "4.48.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.48.1.tgz",
- "integrity": "sha512-3NADEIlt+aCdCbWVZ7D3tBjBX1lHpXxcvrLt/kdXTiBrOds8APTdtk2yRL2GgmnSVeX4YS1JIf0imFujg78vpw==",
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
+ "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
"cpu": [
"riscv64"
],
@@ -3694,9 +4145,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
- "version": "4.48.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.48.1.tgz",
- "integrity": "sha512-euuwm/QTXAMOcyiFCcrx0/S2jGvFlKJ2Iro8rsmYL53dlblp3LkUQVFzEidHhvIPPvcIsxDhl2wkBE+I6YVGzA==",
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
+ "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
"cpu": [
"riscv64"
],
@@ -3708,9 +4159,9 @@
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
- "version": "4.48.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.48.1.tgz",
- "integrity": "sha512-w8mULUjmPdWLJgmTYJx/W6Qhln1a+yqvgwmGXcQl2vFBkWsKGUBRbtLRuKJUln8Uaimf07zgJNxOhHOvjSQmBQ==",
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
+ "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
"cpu": [
"s390x"
],
@@ -3722,9 +4173,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
- "version": "4.48.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.48.1.tgz",
- "integrity": "sha512-90taWXCWxTbClWuMZD0DKYohY1EovA+W5iytpE89oUPmT5O1HFdf8cuuVIylE6vCbrGdIGv85lVRzTcpTRZ+kA==",
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
+ "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
"cpu": [
"x64"
],
@@ -3736,9 +4187,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
- "version": "4.48.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.48.1.tgz",
- "integrity": "sha512-2Gu29SkFh1FfTRuN1GR1afMuND2GKzlORQUP3mNMJbqdndOg7gNsa81JnORctazHRokiDzQ5+MLE5XYmZW5VWg==",
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
+ "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
"cpu": [
"x64"
],
@@ -3749,10 +4200,38 @@
"linux"
]
},
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
+ "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
+ "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
"node_modules/@rollup/rollup-win32-arm64-msvc": {
- "version": "4.48.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.48.1.tgz",
- "integrity": "sha512-6kQFR1WuAO50bxkIlAVeIYsz3RUx+xymwhTo9j94dJ+kmHe9ly7muH23sdfWduD0BA8pD9/yhonUvAjxGh34jQ==",
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
+ "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
"cpu": [
"arm64"
],
@@ -3764,9 +4243,9 @@
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
- "version": "4.48.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.48.1.tgz",
- "integrity": "sha512-RUyZZ/mga88lMI3RlXFs4WQ7n3VyU07sPXmMG7/C1NOi8qisUg57Y7LRarqoGoAiopmGmChUhSwfpvQ3H5iGSQ==",
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
+ "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
"cpu": [
"ia32"
],
@@ -3777,10 +4256,24 @@
"win32"
]
},
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
+ "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
"node_modules/@rollup/rollup-win32-x64-msvc": {
- "version": "4.48.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.48.1.tgz",
- "integrity": "sha512-8a/caCUN4vkTChxkaIJcMtwIVcBhi4X2PQRoT+yCK3qRYaZ7cURrmJFL5Ux9H9RaMIXj9RuihckdmkBX3zZsgg==",
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
+ "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
"cpu": [
"x64"
],
@@ -3792,16 +4285,31 @@
]
},
"node_modules/@sinclair/typebox": {
- "version": "0.27.8",
- "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz",
- "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==",
- "dev": true
+ "version": "0.34.49",
+ "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz",
+ "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==",
+ "dev": true,
+ "license": "MIT"
},
"node_modules/@socket.io/component-emitter": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz",
"integrity": "sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg=="
},
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@standard-schema/utils": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
+ "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@surma/rollup-plugin-off-main-thread": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz",
@@ -3826,9 +4334,9 @@
}
},
"node_modules/@tanstack/query-core": {
- "version": "5.64.1",
- "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.64.1.tgz",
- "integrity": "sha512-978Wx4Wl4UJZbmvU/rkaM9cQtXXrbhK0lsz/UZhYIbyKYA8E4LdomTwyh2GHZ4oU0BKKoDH4YlKk2VscCUgNmg==",
+ "version": "5.95.2",
+ "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.95.2.tgz",
+ "integrity": "sha512-o4T8vZHZET4Bib3jZ/tCW9/7080urD4c+0/AUaYVpIqOsr7y0reBc1oX3ttNaSW5mYyvZHctiQ/UOP2PfdmFEQ==",
"license": "MIT",
"funding": {
"type": "github",
@@ -3836,9 +4344,9 @@
}
},
"node_modules/@tanstack/query-devtools": {
- "version": "5.37.1",
- "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.37.1.tgz",
- "integrity": "sha512-XcG4IIHIv0YQKrexTqo2zogQWR1Sz672tX2KsfE9kzB+9zhx44vRKH5si4WDILE1PIWQpStFs/NnrDQrBAUQpg==",
+ "version": "5.95.2",
+ "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.95.2.tgz",
+ "integrity": "sha512-QfaoqBn9uAZ+ICkA8brd1EHj+qBF6glCFgt94U8XP5BT6ppSsDBI8IJ00BU+cAGjQzp6wcKJL2EmRYvxy0TWIg==",
"dev": true,
"license": "MIT",
"funding": {
@@ -3847,12 +4355,12 @@
}
},
"node_modules/@tanstack/react-query": {
- "version": "5.64.1",
- "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.64.1.tgz",
- "integrity": "sha512-vW5ggHpIO2Yjj44b4sB+Fd3cdnlMJppXRBJkEHvld6FXh3j5dwWJoQo7mGtKI2RbSFyiyu/PhGAy0+Vv5ev9Eg==",
+ "version": "5.95.2",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.95.2.tgz",
+ "integrity": "sha512-/wGkvLj/st5Ud1Q76KF1uFxScV7WeqN1slQx5280ycwAyYkIPGaRZAEgHxe3bjirSd5Zpwkj6zNcR4cqYni/ZA==",
"license": "MIT",
"dependencies": {
- "@tanstack/query-core": "5.64.1"
+ "@tanstack/query-core": "5.95.2"
},
"funding": {
"type": "github",
@@ -3863,30 +4371,30 @@
}
},
"node_modules/@tanstack/react-query-devtools": {
- "version": "5.40.1",
- "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.40.1.tgz",
- "integrity": "sha512-/AN2UsbuL+28/KSlBkVHq/4chHTEp4l2UWTKWixXbn4pprLQrZGmQTAKN4tYxZDuNwNZY5+Zp67pDfXj+F/UBA==",
+ "version": "5.95.2",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.95.2.tgz",
+ "integrity": "sha512-AFQFmbznVkbtfpx8VJ2DylW17wWagQel/qLstVLkYmNRo2CmJt3SNej5hvl6EnEeljJIdC3BTB+W7HZtpsH+3g==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@tanstack/query-devtools": "5.37.1"
+ "@tanstack/query-devtools": "5.95.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
- "@tanstack/react-query": "^5.40.1",
+ "@tanstack/react-query": "^5.95.2",
"react": "^18 || ^19"
}
},
"node_modules/@tanstack/react-table": {
- "version": "8.19.2",
- "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.19.2.tgz",
- "integrity": "sha512-itoSIAkA/Vsg+bjY23FSemcTyPhc5/1YjYyaMsr9QSH/cdbZnQxHVWrpWn0Sp2BWN71qkzR7e5ye8WuMmwyOjg==",
+ "version": "8.21.3",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz",
+ "integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==",
"license": "MIT",
"dependencies": {
- "@tanstack/table-core": "8.19.2"
+ "@tanstack/table-core": "8.21.3"
},
"engines": {
"node": ">=12"
@@ -3900,10 +4408,27 @@
"react-dom": ">=16.8"
}
},
+ "node_modules/@tanstack/react-virtual": {
+ "version": "3.13.23",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.23.tgz",
+ "integrity": "sha512-XnMRnHQ23piOVj2bzJqHrRrLg4r+F86fuBcwteKfbIjJrtGxb4z7tIvPVAe4B+4UVwo9G4Giuz5fmapcrnZ0OQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/virtual-core": "3.13.23"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/@tanstack/table-core": {
- "version": "8.19.2",
- "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.19.2.tgz",
- "integrity": "sha512-KpRjhgehIhbfH78ARm/GJDXGnpdw4bCg3qas6yjWSi7czJhI/J6pWln7NHtmBkGE9ZbohiiNtLqwGzKmBfixig==",
+ "version": "8.21.3",
+ "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
+ "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==",
"license": "MIT",
"engines": {
"node": ">=12"
@@ -3913,20 +4438,30 @@
"url": "https://github.com/sponsors/tannerlinsley"
}
},
+ "node_modules/@tanstack/virtual-core": {
+ "version": "3.13.23",
+ "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.23.tgz",
+ "integrity": "sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
"node_modules/@testing-library/dom": {
- "version": "10.0.0",
- "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.0.0.tgz",
- "integrity": "sha512-PmJPnogldqoVFf+EwbHvbBJ98MmqASV8kLrBYgsDNxQcFMeIS7JFL48sfyXvuMtgmWO/wMhh25odr+8VhDmn4g==",
+ "version": "10.4.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
+ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"dev": true,
- "peer": true,
+ "license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
"@types/aria-query": "^5.0.1",
"aria-query": "5.3.0",
- "chalk": "^4.1.0",
"dom-accessibility-api": "^0.5.9",
"lz-string": "^1.5.0",
+ "picocolors": "1.1.1",
"pretty-format": "^27.0.2"
},
"engines": {
@@ -3934,61 +4469,23 @@
}
},
"node_modules/@testing-library/jest-dom": {
- "version": "6.4.2",
- "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.4.2.tgz",
- "integrity": "sha512-CzqH0AFymEMG48CpzXFriYYkOjk6ZGPCLMhW9e9jg3KMCn5OfJecF8GtGW7yGfR/IgCe3SX8BSwjdzI6BBbZLw==",
+ "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.3.2",
- "@babel/runtime": "^7.9.2",
+ "@adobe/css-tools": "^4.4.0",
"aria-query": "^5.0.0",
- "chalk": "^3.0.0",
"css.escape": "^1.5.1",
"dom-accessibility-api": "^0.6.3",
- "lodash": "^4.17.15",
+ "picocolors": "^1.1.1",
"redent": "^3.0.0"
},
"engines": {
"node": ">=14",
"npm": ">=6",
"yarn": ">=1"
- },
- "peerDependencies": {
- "@jest/globals": ">= 28",
- "@types/bun": "latest",
- "@types/jest": ">= 28",
- "jest": ">= 28",
- "vitest": ">= 0.32"
- },
- "peerDependenciesMeta": {
- "@jest/globals": {
- "optional": true
- },
- "@types/bun": {
- "optional": true
- },
- "@types/jest": {
- "optional": true
- },
- "jest": {
- "optional": true
- },
- "vitest": {
- "optional": true
- }
- }
- },
- "node_modules/@testing-library/jest-dom/node_modules/chalk": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz",
- "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==",
- "dev": true,
- "dependencies": {
- "ansi-styles": "^4.1.0",
- "supports-color": "^7.1.0"
- },
- "engines": {
- "node": ">=8"
}
},
"node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": {
@@ -3998,9 +4495,9 @@
"dev": true
},
"node_modules/@testing-library/react": {
- "version": "16.1.0",
- "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.1.0.tgz",
- "integrity": "sha512-Q2ToPvg0KsVL0ohND9A3zLJWcOXXcO8IDu3fj11KhNt0UlCWyFyvnCIBkd12tidB2lkiVRG8VFqdhcqhqnAQtg==",
+ "version": "16.3.2",
+ "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz",
+ "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4026,10 +4523,11 @@
}
},
"node_modules/@testing-library/user-event": {
- "version": "14.5.2",
- "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.5.2.tgz",
- "integrity": "sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ==",
+ "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"
@@ -4042,8 +4540,7 @@
"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,
- "peer": true
+ "dev": true
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
@@ -4087,84 +4584,87 @@
}
},
"node_modules/@types/chai": {
- "version": "5.2.2",
- "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.2.tgz",
- "integrity": "sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==",
+ "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": "*"
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
}
},
- "node_modules/@types/cookie": {
- "version": "0.6.0",
- "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
- "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
+ "node_modules/@types/d3-array": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
+ "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"dev": true,
"license": "MIT"
},
- "node_modules/@types/d3-array": {
- "version": "3.2.1",
- "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz",
- "integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==",
- "dev": true
- },
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
- "dev": true
+ "dev": true,
+ "license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
- "dev": true
+ "dev": true,
+ "license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.0.tgz",
- "integrity": "sha512-P2dlU/q51fkOc/Gfl3Ul9kicV7l+ra934qBFXCFhrZMOL6du1TM0pm1ThYvENukyOn5h9v+yMJ9Fn5JK4QozrQ==",
- "dev": true
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
+ "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
+ "dev": true,
+ "license": "MIT"
},
"node_modules/@types/d3-scale": {
- "version": "4.0.8",
- "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.8.tgz",
- "integrity": "sha512-gkK1VVTr5iNiYJ7vWDI+yUFFlszhNMtVeneJ6lUTKPjprsvLLI9/tgEGiXJOnlINJA8FyA88gfnQsHbybVZrYQ==",
+ "version": "4.0.9",
+ "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
+ "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
- "version": "3.1.6",
- "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.6.tgz",
- "integrity": "sha512-5KKk5aKGu2I+O6SONMYSNflgiP0WfZIQvVUMan50wHsLG1G94JlxEVnCpQARfTtzytuY0p/9PXXZb3I7giofIA==",
+ "version": "3.1.8",
+ "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
+ "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
- "version": "3.0.3",
- "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.3.tgz",
- "integrity": "sha512-2p6olUZ4w3s+07q3Tm2dbiMZy5pCDfYwtLXXHUnVzXgQlZ/OyPtUz6OL382BkOuGlLXqfT+wqv8Fw2v8/0geBw==",
- "dev": true
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
+ "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
+ "dev": true,
+ "license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
- "dev": true
+ "dev": true,
+ "license": "MIT"
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
@@ -4173,6 +4673,13 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/esrecurse": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
+ "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -4184,13 +4691,15 @@
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
"integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==",
- "dev": true
+ "dev": true,
+ "license": "MIT"
},
"node_modules/@types/istanbul-lib-report": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz",
"integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"@types/istanbul-lib-coverage": "*"
}
@@ -4200,18 +4709,20 @@
"resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz",
"integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"@types/istanbul-lib-report": "*"
}
},
"node_modules/@types/jest": {
- "version": "29.5.12",
- "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.12.tgz",
- "integrity": "sha512-eDC8bTvT/QhYdxJAulQikueigY5AsdBRH2yDKW3yveW7svY3+DzN84/2NUgkw10RTiJbWqZrTtoGVdYlvFJdLw==",
+ "version": "30.0.0",
+ "resolved": "https://registry.npmjs.org/@types/jest/-/jest-30.0.0.tgz",
+ "integrity": "sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "expect": "^29.0.0",
- "pretty-format": "^29.0.0"
+ "expect": "^30.0.0",
+ "pretty-format": "^30.0.0"
}
},
"node_modules/@types/jest/node_modules/ansi-styles": {
@@ -4219,6 +4730,7 @@
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
+ "license": "MIT",
"engines": {
"node": ">=10"
},
@@ -4227,52 +4739,56 @@
}
},
"node_modules/@types/jest/node_modules/pretty-format": {
- "version": "29.7.0",
- "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz",
- "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==",
+ "version": "30.3.0",
+ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz",
+ "integrity": "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@jest/schemas": "^29.6.3",
- "ansi-styles": "^5.0.0",
- "react-is": "^18.0.0"
+ "@jest/schemas": "30.0.5",
+ "ansi-styles": "^5.2.0",
+ "react-is": "^18.3.1"
},
"engines": {
- "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/@types/jest/node_modules/react-is": {
- "version": "18.2.0",
- "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz",
- "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==",
- "dev": true
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
+ "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
+ "dev": true,
+ "license": "MIT"
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
- "dev": true
+ "dev": true,
+ "license": "MIT"
},
"node_modules/@types/lodash": {
- "version": "4.17.1",
- "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.1.tgz",
- "integrity": "sha512-X+2qazGS3jxLAIz5JDXDzglAF3KpijdhFxlf/V1+hEsOUc+HnWi81L/uv/EvGuV90WY+7mPGFCUDGfQC3Gj95Q==",
- "dev": true
+ "version": "4.17.24",
+ "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz",
+ "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==",
+ "dev": true,
+ "license": "MIT"
},
"node_modules/@types/node": {
- "version": "22.14.1",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.1.tgz",
- "integrity": "sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw==",
+ "version": "25.5.0",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
+ "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "undici-types": "~6.21.0"
+ "undici-types": "~7.18.0"
}
},
"node_modules/@types/react": {
- "version": "19.2.7",
- "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
- "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
- "devOptional": true,
+ "version": "19.2.14",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
+ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@@ -4295,29 +4811,17 @@
"dev": true,
"license": "MIT"
},
- "node_modules/@types/semver": {
- "version": "7.5.8",
- "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz",
- "integrity": "sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==",
- "dev": true
- },
"node_modules/@types/stack-utils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz",
"integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==",
- "dev": true
- },
- "node_modules/@types/statuses": {
- "version": "2.0.5",
- "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.5.tgz",
- "integrity": "sha512-jmIUGWrAiwu3dZpxntxieC+1n/5c3mjrImkmOSQ2NC5uP6cYO4aAZDdSmRcI5C1oiTmqlZGHC+/NmJrKogbP5A==",
"dev": true,
"license": "MIT"
},
- "node_modules/@types/tough-cookie": {
- "version": "4.0.5",
- "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz",
- "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==",
+ "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"
},
@@ -4328,11 +4832,19 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/use-sync-external-store": {
+ "version": "0.0.6",
+ "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
+ "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/yargs": {
- "version": "17.0.32",
- "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz",
- "integrity": "sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==",
+ "version": "17.0.35",
+ "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz",
+ "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"@types/yargs-parser": "*"
}
@@ -4341,523 +4853,238 @@
"version": "21.0.3",
"resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz",
"integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==",
- "dev": true
+ "dev": true,
+ "license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
- "version": "7.16.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.16.0.tgz",
- "integrity": "sha512-py1miT6iQpJcs1BiJjm54AMzeuMPBSPuKPlnT8HlfudbcS5rYeX5jajpLf3mrdRh9dA/Ec2FVUY0ifeVNDIhZw==",
+ "version": "8.57.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.2.tgz",
+ "integrity": "sha512-NZZgp0Fm2IkD+La5PR81sd+g+8oS6JwJje+aRWsDocxHkjyRw0J5L5ZTlN3LI1LlOcGL7ph3eaIUmTXMIjLk0w==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@eslint-community/regexpp": "^4.10.0",
- "@typescript-eslint/scope-manager": "7.16.0",
- "@typescript-eslint/type-utils": "7.16.0",
- "@typescript-eslint/utils": "7.16.0",
- "@typescript-eslint/visitor-keys": "7.16.0",
- "graphemer": "^1.4.0",
- "ignore": "^5.3.1",
+ "@eslint-community/regexpp": "^4.12.2",
+ "@typescript-eslint/scope-manager": "8.57.2",
+ "@typescript-eslint/type-utils": "8.57.2",
+ "@typescript-eslint/utils": "8.57.2",
+ "@typescript-eslint/visitor-keys": "8.57.2",
+ "ignore": "^7.0.5",
"natural-compare": "^1.4.0",
- "ts-api-utils": "^1.3.0"
- },
- "engines": {
- "node": "^18.18.0 || >=20.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/typescript-eslint"
- },
- "peerDependencies": {
- "@typescript-eslint/parser": "^7.0.0",
- "eslint": "^8.56.0"
- },
- "peerDependenciesMeta": {
- "typescript": {
- "optional": true
- }
- }
- },
- "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/scope-manager": {
- "version": "7.16.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.16.0.tgz",
- "integrity": "sha512-8gVv3kW6n01Q6TrI1cmTZ9YMFi3ucDT7i7aI5lEikk2ebk1AEjrwX8MDTdaX5D7fPXMBLvnsaa0IFTAu+jcfOw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@typescript-eslint/types": "7.16.0",
- "@typescript-eslint/visitor-keys": "7.16.0"
- },
- "engines": {
- "node": "^18.18.0 || >=20.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/typescript-eslint"
- }
- },
- "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/types": {
- "version": "7.16.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.16.0.tgz",
- "integrity": "sha512-fecuH15Y+TzlUutvUl9Cc2XJxqdLr7+93SQIbcZfd4XRGGKoxyljK27b+kxKamjRkU7FYC6RrbSCg0ALcZn/xw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": "^18.18.0 || >=20.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/typescript-eslint"
- }
- },
- "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/typescript-estree": {
- "version": "7.16.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.16.0.tgz",
- "integrity": "sha512-a5NTvk51ZndFuOLCh5OaJBELYc2O3Zqxfl3Js78VFE1zE46J2AaVuW+rEbVkQznjkmlzWsUI15BG5tQMixzZLw==",
- "dev": true,
- "license": "BSD-2-Clause",
- "dependencies": {
- "@typescript-eslint/types": "7.16.0",
- "@typescript-eslint/visitor-keys": "7.16.0",
- "debug": "^4.3.4",
- "globby": "^11.1.0",
- "is-glob": "^4.0.3",
- "minimatch": "^9.0.4",
- "semver": "^7.6.0",
- "ts-api-utils": "^1.3.0"
- },
- "engines": {
- "node": "^18.18.0 || >=20.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/typescript-eslint"
- },
- "peerDependenciesMeta": {
- "typescript": {
- "optional": true
- }
- }
- },
- "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/utils": {
- "version": "7.16.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.16.0.tgz",
- "integrity": "sha512-PqP4kP3hb4r7Jav+NiRCntlVzhxBNWq6ZQ+zQwII1y/G/1gdIPeYDCKr2+dH6049yJQsWZiHU6RlwvIFBXXGNA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@eslint-community/eslint-utils": "^4.4.0",
- "@typescript-eslint/scope-manager": "7.16.0",
- "@typescript-eslint/types": "7.16.0",
- "@typescript-eslint/typescript-estree": "7.16.0"
+ "ts-api-utils": "^2.4.0"
},
"engines": {
- "node": "^18.18.0 || >=20.0.0"
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "eslint": "^8.56.0"
- }
- },
- "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/visitor-keys": {
- "version": "7.16.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.16.0.tgz",
- "integrity": "sha512-rMo01uPy9C7XxG7AFsxa8zLnWXTF8N3PYclekWSrurvhwiw1eW88mrKiAYe6s53AUY57nTRz8dJsuuXdkAhzCg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@typescript-eslint/types": "7.16.0",
- "eslint-visitor-keys": "^3.4.3"
- },
- "engines": {
- "node": "^18.18.0 || >=20.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/typescript-eslint"
+ "@typescript-eslint/parser": "^8.57.2",
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
}
},
- "node_modules/@typescript-eslint/eslint-plugin/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==",
+ "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
+ "version": "7.0.5",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
+ "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
"dev": true,
"license": "MIT",
- "dependencies": {
- "balanced-match": "^1.0.0"
- }
- },
- "node_modules/@typescript-eslint/eslint-plugin/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/@typescript-eslint/eslint-plugin/node_modules/semver": {
- "version": "7.6.2",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz",
- "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==",
- "dev": true,
- "license": "ISC",
- "bin": {
- "semver": "bin/semver.js"
- },
"engines": {
- "node": ">=10"
+ "node": ">= 4"
}
},
"node_modules/@typescript-eslint/parser": {
- "version": "7.16.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.16.0.tgz",
- "integrity": "sha512-ar9E+k7CU8rWi2e5ErzQiC93KKEFAXA2Kky0scAlPcxYblLt8+XZuHUZwlyfXILyQa95P6lQg+eZgh/dDs3+Vw==",
+ "version": "8.57.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.2.tgz",
+ "integrity": "sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA==",
"dev": true,
- "license": "BSD-2-Clause",
+ "license": "MIT",
"dependencies": {
- "@typescript-eslint/scope-manager": "7.16.0",
- "@typescript-eslint/types": "7.16.0",
- "@typescript-eslint/typescript-estree": "7.16.0",
- "@typescript-eslint/visitor-keys": "7.16.0",
- "debug": "^4.3.4"
+ "@typescript-eslint/scope-manager": "8.57.2",
+ "@typescript-eslint/types": "8.57.2",
+ "@typescript-eslint/typescript-estree": "8.57.2",
+ "@typescript-eslint/visitor-keys": "8.57.2",
+ "debug": "^4.4.3"
},
"engines": {
- "node": "^18.18.0 || >=20.0.0"
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "eslint": "^8.56.0"
- },
- "peerDependenciesMeta": {
- "typescript": {
- "optional": true
- }
- }
- },
- "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/scope-manager": {
- "version": "7.16.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.16.0.tgz",
- "integrity": "sha512-8gVv3kW6n01Q6TrI1cmTZ9YMFi3ucDT7i7aI5lEikk2ebk1AEjrwX8MDTdaX5D7fPXMBLvnsaa0IFTAu+jcfOw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@typescript-eslint/types": "7.16.0",
- "@typescript-eslint/visitor-keys": "7.16.0"
- },
- "engines": {
- "node": "^18.18.0 || >=20.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/typescript-eslint"
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
}
},
- "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/types": {
- "version": "7.16.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.16.0.tgz",
- "integrity": "sha512-fecuH15Y+TzlUutvUl9Cc2XJxqdLr7+93SQIbcZfd4XRGGKoxyljK27b+kxKamjRkU7FYC6RrbSCg0ALcZn/xw==",
+ "node_modules/@typescript-eslint/parser/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
- "engines": {
- "node": "^18.18.0 || >=20.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/typescript-eslint"
- }
- },
- "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree": {
- "version": "7.16.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.16.0.tgz",
- "integrity": "sha512-a5NTvk51ZndFuOLCh5OaJBELYc2O3Zqxfl3Js78VFE1zE46J2AaVuW+rEbVkQznjkmlzWsUI15BG5tQMixzZLw==",
- "dev": true,
- "license": "BSD-2-Clause",
"dependencies": {
- "@typescript-eslint/types": "7.16.0",
- "@typescript-eslint/visitor-keys": "7.16.0",
- "debug": "^4.3.4",
- "globby": "^11.1.0",
- "is-glob": "^4.0.3",
- "minimatch": "^9.0.4",
- "semver": "^7.6.0",
- "ts-api-utils": "^1.3.0"
+ "ms": "^2.1.3"
},
"engines": {
- "node": "^18.18.0 || >=20.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/typescript-eslint"
+ "node": ">=6.0"
},
"peerDependenciesMeta": {
- "typescript": {
+ "supports-color": {
"optional": true
}
}
},
- "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/visitor-keys": {
- "version": "7.16.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.16.0.tgz",
- "integrity": "sha512-rMo01uPy9C7XxG7AFsxa8zLnWXTF8N3PYclekWSrurvhwiw1eW88mrKiAYe6s53AUY57nTRz8dJsuuXdkAhzCg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@typescript-eslint/types": "7.16.0",
- "eslint-visitor-keys": "^3.4.3"
- },
- "engines": {
- "node": "^18.18.0 || >=20.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/typescript-eslint"
- }
- },
- "node_modules/@typescript-eslint/parser/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/@typescript-eslint/parser/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/@typescript-eslint/parser/node_modules/semver": {
- "version": "7.6.2",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz",
- "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==",
- "dev": true,
- "license": "ISC",
- "bin": {
- "semver": "bin/semver.js"
- },
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/@typescript-eslint/scope-manager": {
- "version": "5.62.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.62.0.tgz",
- "integrity": "sha512-VXuvVvZeQCQb5Zgf4HAxc04q5j+WrNAtNh9OwCsCgpKqESMTu3tF/jhZ3xG6T4NZwWl65Bg8KuS2uEvhSfLl0w==",
- "dev": true,
- "dependencies": {
- "@typescript-eslint/types": "5.62.0",
- "@typescript-eslint/visitor-keys": "5.62.0"
- },
- "engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/typescript-eslint"
- }
- },
- "node_modules/@typescript-eslint/type-utils": {
- "version": "7.16.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.16.0.tgz",
- "integrity": "sha512-j0fuUswUjDHfqV/UdW6mLtOQQseORqfdmoBNDFOqs9rvNVR2e+cmu6zJu/Ku4SDuqiJko6YnhwcL8x45r8Oqxg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@typescript-eslint/typescript-estree": "7.16.0",
- "@typescript-eslint/utils": "7.16.0",
- "debug": "^4.3.4",
- "ts-api-utils": "^1.3.0"
- },
- "engines": {
- "node": "^18.18.0 || >=20.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/typescript-eslint"
- },
- "peerDependencies": {
- "eslint": "^8.56.0"
- },
- "peerDependenciesMeta": {
- "typescript": {
- "optional": true
- }
- }
+ "node_modules/@typescript-eslint/parser/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==",
+ "dev": true,
+ "license": "MIT"
},
- "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/scope-manager": {
- "version": "7.16.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.16.0.tgz",
- "integrity": "sha512-8gVv3kW6n01Q6TrI1cmTZ9YMFi3ucDT7i7aI5lEikk2ebk1AEjrwX8MDTdaX5D7fPXMBLvnsaa0IFTAu+jcfOw==",
+ "node_modules/@typescript-eslint/project-service": {
+ "version": "8.57.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.2.tgz",
+ "integrity": "sha512-FuH0wipFywXRTHf+bTTjNyuNQQsQC3qh/dYzaM4I4W0jrCqjCVuUh99+xd9KamUfmCGPvbO8NDngo/vsnNVqgw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/types": "7.16.0",
- "@typescript-eslint/visitor-keys": "7.16.0"
+ "@typescript-eslint/tsconfig-utils": "^8.57.2",
+ "@typescript-eslint/types": "^8.57.2",
+ "debug": "^4.4.3"
},
"engines": {
- "node": "^18.18.0 || >=20.0.0"
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.0.0"
}
},
- "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/types": {
- "version": "7.16.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.16.0.tgz",
- "integrity": "sha512-fecuH15Y+TzlUutvUl9Cc2XJxqdLr7+93SQIbcZfd4XRGGKoxyljK27b+kxKamjRkU7FYC6RrbSCg0ALcZn/xw==",
+ "node_modules/@typescript-eslint/project-service/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
"engines": {
- "node": "^18.18.0 || >=20.0.0"
+ "node": ">=6.0"
},
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/typescript-eslint"
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
}
},
- "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/typescript-estree": {
- "version": "7.16.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.16.0.tgz",
- "integrity": "sha512-a5NTvk51ZndFuOLCh5OaJBELYc2O3Zqxfl3Js78VFE1zE46J2AaVuW+rEbVkQznjkmlzWsUI15BG5tQMixzZLw==",
+ "node_modules/@typescript-eslint/project-service/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==",
"dev": true,
- "license": "BSD-2-Clause",
+ "license": "MIT"
+ },
+ "node_modules/@typescript-eslint/scope-manager": {
+ "version": "8.57.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.2.tgz",
+ "integrity": "sha512-snZKH+W4WbWkrBqj4gUNRIGb/jipDW3qMqVJ4C9rzdFc+wLwruxk+2a5D+uoFcKPAqyqEnSb4l2ULuZf95eSkw==",
+ "dev": true,
+ "license": "MIT",
"dependencies": {
- "@typescript-eslint/types": "7.16.0",
- "@typescript-eslint/visitor-keys": "7.16.0",
- "debug": "^4.3.4",
- "globby": "^11.1.0",
- "is-glob": "^4.0.3",
- "minimatch": "^9.0.4",
- "semver": "^7.6.0",
- "ts-api-utils": "^1.3.0"
+ "@typescript-eslint/types": "8.57.2",
+ "@typescript-eslint/visitor-keys": "8.57.2"
},
"engines": {
- "node": "^18.18.0 || >=20.0.0"
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
- },
- "peerDependenciesMeta": {
- "typescript": {
- "optional": true
- }
}
},
- "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/utils": {
- "version": "7.16.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.16.0.tgz",
- "integrity": "sha512-PqP4kP3hb4r7Jav+NiRCntlVzhxBNWq6ZQ+zQwII1y/G/1gdIPeYDCKr2+dH6049yJQsWZiHU6RlwvIFBXXGNA==",
+ "node_modules/@typescript-eslint/tsconfig-utils": {
+ "version": "8.57.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.2.tgz",
+ "integrity": "sha512-3Lm5DSM+DCowsUOJC+YqHHnKEfFh5CoGkj5Z31NQSNF4l5wdOwqGn99wmwN/LImhfY3KJnmordBq/4+VDe2eKw==",
"dev": true,
"license": "MIT",
- "dependencies": {
- "@eslint-community/eslint-utils": "^4.4.0",
- "@typescript-eslint/scope-manager": "7.16.0",
- "@typescript-eslint/types": "7.16.0",
- "@typescript-eslint/typescript-estree": "7.16.0"
- },
"engines": {
- "node": "^18.18.0 || >=20.0.0"
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "eslint": "^8.56.0"
+ "typescript": ">=4.8.4 <6.0.0"
}
},
- "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/visitor-keys": {
- "version": "7.16.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.16.0.tgz",
- "integrity": "sha512-rMo01uPy9C7XxG7AFsxa8zLnWXTF8N3PYclekWSrurvhwiw1eW88mrKiAYe6s53AUY57nTRz8dJsuuXdkAhzCg==",
+ "node_modules/@typescript-eslint/type-utils": {
+ "version": "8.57.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.2.tgz",
+ "integrity": "sha512-Co6ZCShm6kIbAM/s+oYVpKFfW7LBc6FXoPXjTRQ449PPNBY8U0KZXuevz5IFuuUj2H9ss40atTaf9dlGLzbWZg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/types": "7.16.0",
- "eslint-visitor-keys": "^3.4.3"
+ "@typescript-eslint/types": "8.57.2",
+ "@typescript-eslint/typescript-estree": "8.57.2",
+ "@typescript-eslint/utils": "8.57.2",
+ "debug": "^4.4.3",
+ "ts-api-utils": "^2.4.0"
},
"engines": {
- "node": "^18.18.0 || >=20.0.0"
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
}
},
- "node_modules/@typescript-eslint/type-utils/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==",
+ "node_modules/@typescript-eslint/type-utils/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "balanced-match": "^1.0.0"
- }
- },
- "node_modules/@typescript-eslint/type-utils/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"
+ "ms": "^2.1.3"
},
"engines": {
- "node": ">=16 || 14 >=14.17"
+ "node": ">=6.0"
},
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
}
},
- "node_modules/@typescript-eslint/type-utils/node_modules/semver": {
- "version": "7.6.2",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz",
- "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==",
+ "node_modules/@typescript-eslint/type-utils/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==",
"dev": true,
- "license": "ISC",
- "bin": {
- "semver": "bin/semver.js"
- },
- "engines": {
- "node": ">=10"
- }
+ "license": "MIT"
},
"node_modules/@typescript-eslint/types": {
- "version": "5.62.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.62.0.tgz",
- "integrity": "sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ==",
+ "version": "8.57.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.2.tgz",
+ "integrity": "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==",
"dev": true,
+ "license": "MIT",
"engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
@@ -4865,52 +5092,64 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
- "version": "5.62.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.62.0.tgz",
- "integrity": "sha512-CmcQ6uY7b9y694lKdRB8FEel7JbU/40iSAPomu++SjLMntB+2Leay2LO6i8VnJk58MtE9/nQSFIH6jpyRWyYzA==",
+ "version": "8.57.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.2.tgz",
+ "integrity": "sha512-2MKM+I6g8tJxfSmFKOnHv2t8Sk3T6rF20A1Puk0svLK+uVapDZB/4pfAeB7nE83uAZrU6OxW+HmOd5wHVdXwXA==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@typescript-eslint/types": "5.62.0",
- "@typescript-eslint/visitor-keys": "5.62.0",
- "debug": "^4.3.4",
- "globby": "^11.1.0",
- "is-glob": "^4.0.3",
- "semver": "^7.3.7",
- "tsutils": "^3.21.0"
+ "@typescript-eslint/project-service": "8.57.2",
+ "@typescript-eslint/tsconfig-utils": "8.57.2",
+ "@typescript-eslint/types": "8.57.2",
+ "@typescript-eslint/visitor-keys": "8.57.2",
+ "debug": "^4.4.3",
+ "minimatch": "^10.2.2",
+ "semver": "^7.7.3",
+ "tinyglobby": "^0.2.15",
+ "ts-api-utils": "^2.4.0"
},
"engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
- "peerDependenciesMeta": {
- "typescript": {
- "optional": true
- }
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.0.0"
}
},
- "node_modules/@typescript-eslint/typescript-estree/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==",
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "yallist": "^4.0.0"
+ "ms": "^2.1.3"
},
"engines": {
- "node": ">=10"
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
}
},
+ "node_modules/@typescript-eslint/typescript-estree/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==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
- "version": "7.6.0",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz",
- "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==",
+ "version": "7.7.4",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
+ "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"dev": true,
- "dependencies": {
- "lru-cache": "^6.0.0"
- },
+ "license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
@@ -4918,120 +5157,65 @@
"node": ">=10"
}
},
- "node_modules/@typescript-eslint/typescript-estree/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
- },
"node_modules/@typescript-eslint/utils": {
- "version": "5.62.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.62.0.tgz",
- "integrity": "sha512-n8oxjeb5aIbPFEtmQxQYOLI0i9n5ySBEY/ZEHHZqKQSFnxio1rv6dthascc9dLuwrL0RC5mPCxB7vnAVGAYWAQ==",
+ "version": "8.57.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.2.tgz",
+ "integrity": "sha512-krRIbvPK1ju1WBKIefiX+bngPs+odIQUtR7kymzPfo1POVw3jlF+nLkmexdSSd4UCbDcQn+wMBATOOmpBbqgKg==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@eslint-community/eslint-utils": "^4.2.0",
- "@types/json-schema": "^7.0.9",
- "@types/semver": "^7.3.12",
- "@typescript-eslint/scope-manager": "5.62.0",
- "@typescript-eslint/types": "5.62.0",
- "@typescript-eslint/typescript-estree": "5.62.0",
- "eslint-scope": "^5.1.1",
- "semver": "^7.3.7"
+ "@eslint-community/eslint-utils": "^4.9.1",
+ "@typescript-eslint/scope-manager": "8.57.2",
+ "@typescript-eslint/types": "8.57.2",
+ "@typescript-eslint/typescript-estree": "8.57.2"
},
"engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0"
- }
- },
- "node_modules/@typescript-eslint/utils/node_modules/eslint-scope": {
- "version": "5.1.1",
- "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz",
- "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==",
- "dev": true,
- "dependencies": {
- "esrecurse": "^4.3.0",
- "estraverse": "^4.1.1"
- },
- "engines": {
- "node": ">=8.0.0"
- }
- },
- "node_modules/@typescript-eslint/utils/node_modules/estraverse": {
- "version": "4.3.0",
- "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz",
- "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
- "dev": true,
- "engines": {
- "node": ">=4.0"
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
}
},
- "node_modules/@typescript-eslint/utils/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==",
+ "node_modules/@typescript-eslint/visitor-keys": {
+ "version": "8.57.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.2.tgz",
+ "integrity": "sha512-zhahknjobV2FiD6Ee9iLbS7OV9zi10rG26odsQdfBO/hjSzUQbkIYgda+iNKK1zNiW2ey+Lf8MU5btN17V3dUw==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "yallist": "^4.0.0"
+ "@typescript-eslint/types": "8.57.2",
+ "eslint-visitor-keys": "^5.0.0"
},
"engines": {
- "node": ">=10"
- }
- },
- "node_modules/@typescript-eslint/utils/node_modules/semver": {
- "version": "7.6.0",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz",
- "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==",
- "dev": true,
- "dependencies": {
- "lru-cache": "^6.0.0"
- },
- "bin": {
- "semver": "bin/semver.js"
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
- "engines": {
- "node": ">=10"
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
}
},
- "node_modules/@typescript-eslint/utils/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
- },
- "node_modules/@typescript-eslint/visitor-keys": {
- "version": "5.62.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.62.0.tgz",
- "integrity": "sha512-07ny+LHRzQXepkGg6w0mFY41fVUNBrL2Roj/++7V1txKugfjm/Ci/qSND03r2RhlJhJYMcTn9AhhSSqQp0Ysyw==",
+ "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
+ "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
"dev": true,
- "dependencies": {
- "@typescript-eslint/types": "5.62.0",
- "eslint-visitor-keys": "^3.3.0"
- },
+ "license": "Apache-2.0",
"engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ "node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/typescript-eslint"
+ "url": "https://opencollective.com/eslint"
}
},
- "node_modules/@ungap/structured-clone": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz",
- "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==",
- "dev": true
- },
"node_modules/@vite-pwa/assets-generator": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/@vite-pwa/assets-generator/-/assets-generator-1.0.0.tgz",
- "integrity": "sha512-tWRF/tsqGkND5+dDVnJz7DzQkIRjtTRRYvA3y6l4FwTwK47OK72p1X7ResSz6T7PimIZMuFd+arsB8NRIG+Sww==",
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@vite-pwa/assets-generator/-/assets-generator-1.0.2.tgz",
+ "integrity": "sha512-MCbrb508JZHqe7bUibmZj/lyojdhLRnfkmyXnkrCM2zVrjTgL89U8UEfInpKTvPeTnxsw2hmyZxnhsdNR6yhwg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -5053,53 +5237,81 @@
}
},
"node_modules/@vitejs/plugin-react": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.0.1.tgz",
- "integrity": "sha512-DE4UNaBXwtVoDJ0ccBdLVjFTWL70NRuWNCxEieTI3lrq9ORB9aOCQEKstwDXBl87NvFdbqh/p7eINGyj0BthJA==",
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz",
+ "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/core": "^7.28.3",
+ "@babel/core": "^7.29.0",
"@babel/plugin-transform-react-jsx-self": "^7.27.1",
"@babel/plugin-transform-react-jsx-source": "^7.27.1",
- "@rolldown/pluginutils": "1.0.0-beta.32",
+ "@rolldown/pluginutils": "1.0.0-rc.3",
"@types/babel__core": "^7.20.5",
- "react-refresh": "^0.17.0"
+ "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"
+ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
+ }
+ },
+ "node_modules/@vitejs/plugin-react/node_modules/@babel/core": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
+ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.0",
+ "@babel/generator": "^7.29.0",
+ "@babel/helper-compilation-targets": "^7.28.6",
+ "@babel/helper-module-transforms": "^7.28.6",
+ "@babel/helpers": "^7.28.6",
+ "@babel/parser": "^7.29.0",
+ "@babel/template": "^7.28.6",
+ "@babel/traverse": "^7.29.0",
+ "@babel/types": "^7.29.0",
+ "@jridgewell/remapping": "^2.3.5",
+ "convert-source-map": "^2.0.0",
+ "debug": "^4.1.0",
+ "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/@vitest/coverage-v8": {
- "version": "3.2.4",
- "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz",
- "integrity": "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==",
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.2.tgz",
+ "integrity": "sha512-sPK//PHO+kAkScb8XITeB1bf7fsk85Km7+rt4eeuRR3VS1/crD47cmV5wicisJmjNdfeokTZwjMk4Mj2d58Mgg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@ampproject/remapping": "^2.3.0",
"@bcoe/v8-coverage": "^1.0.2",
- "ast-v8-to-istanbul": "^0.3.3",
- "debug": "^4.4.1",
+ "@vitest/utils": "4.1.2",
+ "ast-v8-to-istanbul": "^1.0.0",
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
- "istanbul-lib-source-maps": "^5.0.6",
- "istanbul-reports": "^3.1.7",
- "magic-string": "^0.30.17",
- "magicast": "^0.3.5",
- "std-env": "^3.9.0",
- "test-exclude": "^7.0.1",
- "tinyrainbow": "^2.0.0"
+ "istanbul-reports": "^3.2.0",
+ "magicast": "^0.5.2",
+ "obug": "^2.1.1",
+ "std-env": "^4.0.0-rc.1",
+ "tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
- "@vitest/browser": "3.2.4",
- "vitest": "3.2.4"
+ "@vitest/browser": "4.1.2",
+ "vitest": "4.1.2"
},
"peerDependenciesMeta": {
"@vitest/browser": {
@@ -5107,65 +5319,41 @@
}
}
},
- "node_modules/@vitest/coverage-v8/node_modules/debug": {
- "version": "4.4.1",
- "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
- "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "ms": "^2.1.3"
- },
- "engines": {
- "node": ">=6.0"
- },
- "peerDependenciesMeta": {
- "supports-color": {
- "optional": true
- }
- }
- },
- "node_modules/@vitest/coverage-v8/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==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/@vitest/expect": {
- "version": "3.2.4",
- "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
- "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==",
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.2.tgz",
+ "integrity": "sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==",
"dev": true,
"license": "MIT",
"dependencies": {
+ "@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
- "@vitest/spy": "3.2.4",
- "@vitest/utils": "3.2.4",
- "chai": "^5.2.0",
- "tinyrainbow": "^2.0.0"
+ "@vitest/spy": "4.1.2",
+ "@vitest/utils": "4.1.2",
+ "chai": "^6.2.2",
+ "tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/mocker": {
- "version": "3.2.4",
- "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz",
- "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==",
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.2.tgz",
+ "integrity": "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/spy": "3.2.4",
+ "@vitest/spy": "4.1.2",
"estree-walker": "^3.0.3",
- "magic-string": "^0.30.17"
+ "magic-string": "^0.30.21"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
- "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"msw": {
@@ -5177,42 +5365,42 @@
}
},
"node_modules/@vitest/pretty-format": {
- "version": "3.2.4",
- "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz",
- "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==",
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.2.tgz",
+ "integrity": "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "tinyrainbow": "^2.0.0"
+ "tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
- "version": "3.2.4",
- "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz",
- "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==",
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.2.tgz",
+ "integrity": "sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/utils": "3.2.4",
- "pathe": "^2.0.3",
- "strip-literal": "^3.0.0"
+ "@vitest/utils": "4.1.2",
+ "pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot": {
- "version": "3.2.4",
- "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz",
- "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==",
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.2.tgz",
+ "integrity": "sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/pretty-format": "3.2.4",
- "magic-string": "^0.30.17",
+ "@vitest/pretty-format": "4.1.2",
+ "@vitest/utils": "4.1.2",
+ "magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
"funding": {
@@ -5220,60 +5408,58 @@
}
},
"node_modules/@vitest/spy": {
- "version": "3.2.4",
- "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz",
- "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==",
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.2.tgz",
+ "integrity": "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==",
"dev": true,
"license": "MIT",
- "dependencies": {
- "tinyspy": "^4.0.3"
- },
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/ui": {
- "version": "3.2.4",
- "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-3.2.4.tgz",
- "integrity": "sha512-hGISOaP18plkzbWEcP/QvtRW1xDXF2+96HbEX6byqQhAUbiS5oH6/9JwW+QsQCIYON2bI6QZBF+2PvOmrRZ9wA==",
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.2.tgz",
+ "integrity": "sha512-/irhyeAcKS2u6Zokagf9tqZJ0t8S6kMZq4ZG9BHZv7I+fkRrYfQX4w7geYeC2r6obThz39PDxvXQzZX+qXqGeg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/utils": "3.2.4",
+ "@vitest/utils": "4.1.2",
"fflate": "^0.8.2",
- "flatted": "^3.3.3",
+ "flatted": "^3.4.2",
"pathe": "^2.0.3",
- "sirv": "^3.0.1",
- "tinyglobby": "^0.2.14",
- "tinyrainbow": "^2.0.0"
+ "sirv": "^3.0.2",
+ "tinyglobby": "^0.2.15",
+ "tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
- "vitest": "3.2.4"
+ "vitest": "4.1.2"
}
},
"node_modules/@vitest/utils": {
- "version": "3.2.4",
- "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz",
- "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==",
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.2.tgz",
+ "integrity": "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/pretty-format": "3.2.4",
- "loupe": "^3.1.4",
- "tinyrainbow": "^2.0.0"
+ "@vitest/pretty-format": "4.1.2",
+ "convert-source-map": "^2.0.0",
+ "tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/acorn": {
- "version": "8.11.3",
- "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz",
- "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==",
+ "version": "8.16.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
+ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
+ "license": "MIT",
"bin": {
"acorn": "bin/acorn"
},
@@ -5286,6 +5472,7 @@
"resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
"integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
"dev": true,
+ "license": "MIT",
"peerDependencies": {
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
@@ -5301,10 +5488,11 @@
}
},
"node_modules/ajv": {
- "version": "6.12.6",
- "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
- "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
+ "version": "6.14.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
+ "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -5355,12 +5543,6 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
- "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==",
- "dev": true
- },
"node_modules/aria-query": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
@@ -5387,15 +5569,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/array-union": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz",
- "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==",
- "dev": true,
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/arraybuffer.prototype.slice": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz",
@@ -5429,21 +5602,21 @@
}
},
"node_modules/ast-v8-to-istanbul": {
- "version": "0.3.4",
- "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.4.tgz",
- "integrity": "sha512-cxrAnZNLBnQwBPByK4CeDaw5sWZtMilJE/Q3iDA0aamgaIVNDF9T6K2/8DfYDZEejZ2jNnDrG9m8MY72HFd0KA==",
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz",
+ "integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@jridgewell/trace-mapping": "^0.3.29",
+ "@jridgewell/trace-mapping": "^0.3.31",
"estree-walker": "^3.0.3",
- "js-tokens": "^9.0.1"
+ "js-tokens": "^10.0.0"
}
},
"node_modules/ast-v8-to-istanbul/node_modules/js-tokens": {
- "version": "9.0.1",
- "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
- "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
+ "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==",
"dev": true,
"license": "MIT"
},
@@ -5467,7 +5640,8 @@
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
- "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
+ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+ "license": "MIT"
},
"node_modules/at-least-node": {
"version": "1.0.0",
@@ -5505,25 +5679,25 @@
}
},
"node_modules/axios": {
- "version": "1.12.2",
- "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
- "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
+ "version": "1.14.0",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz",
+ "integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==",
"license": "MIT",
"dependencies": {
- "follow-redirects": "^1.15.6",
- "form-data": "^4.0.4",
- "proxy-from-env": "^1.1.0"
+ "follow-redirects": "^1.15.11",
+ "form-data": "^4.0.5",
+ "proxy-from-env": "^2.1.0"
}
},
"node_modules/babel-plugin-polyfill-corejs2": {
- "version": "0.4.13",
- "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.13.tgz",
- "integrity": "sha512-3sX/eOms8kd3q2KZ6DAhKPc0dgm525Gqq5NtWKZ7QYYZEv57OQ54KtblzJzH1lQF/eQxO8KjWGIK9IPUJNus5g==",
+ "version": "0.4.16",
+ "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.16.tgz",
+ "integrity": "sha512-xaVwwSfebXf0ooE11BJovZYKhFjIvQo7TsyVpETuIeH2JHv0k/T6Y5j22pPTvqYqmpkxdlPAJlyJ0tfOJAoMxw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/compat-data": "^7.22.6",
- "@babel/helper-define-polyfill-provider": "^0.6.4",
+ "@babel/compat-data": "^7.28.6",
+ "@babel/helper-define-polyfill-provider": "^0.6.7",
"semver": "^6.3.1"
},
"peerDependencies": {
@@ -5531,47 +5705,82 @@
}
},
"node_modules/babel-plugin-polyfill-corejs3": {
- "version": "0.11.1",
- "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.11.1.tgz",
- "integrity": "sha512-yGCqvBT4rwMczo28xkH/noxJ6MZ4nJfkVYdoDaC/utLtWrXxv27HVrzAeSbqR8SxDsp46n0YF47EbHoixy6rXQ==",
+ "version": "0.14.1",
+ "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.14.1.tgz",
+ "integrity": "sha512-ENp89vM9Pw4kv/koBb5N2f9bDZsR0hpf3BdPMOg/pkS3pwO4dzNnQZVXtBbeyAadgm865DmQG2jMMLqmZXvuCw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-define-polyfill-provider": "^0.6.7",
+ "core-js-compat": "^3.48.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
+ }
+ },
+ "node_modules/babel-plugin-polyfill-regenerator": {
+ "version": "0.6.7",
+ "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.7.tgz",
+ "integrity": "sha512-OTYbUlSwXhNgr4g6efMZgsO8//jA61P7ZbRX3iTT53VON8l+WQS8IAUEVo4a4cWknrg2W8Cj4gQhRYNCJ8GkAA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-define-polyfill-provider": "^0.6.3",
- "core-js-compat": "^3.40.0"
+ "@babel/helper-define-polyfill-provider": "^0.6.7"
},
"peerDependencies": {
"@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
}
},
- "node_modules/babel-plugin-polyfill-regenerator": {
- "version": "0.6.4",
- "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.4.tgz",
- "integrity": "sha512-7gD3pRadPrbjhjLyxebmx/WrFYcuSjZ0XbdUujQMZ/fcE9oeewk2U/7PCvez84UeuK3oSjmPZ0Ch0dlupQvGzw==",
+ "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
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.10.0",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
+ "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "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/brace-expansion": {
+ "version": "5.0.5",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
+ "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-define-polyfill-provider": "^0.6.4"
+ "balanced-match": "^4.0.2"
},
- "peerDependencies": {
- "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
+ "engines": {
+ "node": "18 || 20 || >=22"
}
},
- "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
- },
- "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==",
+ "node_modules/brace-expansion/node_modules/balanced-match": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
- "dependencies": {
- "balanced-match": "^1.0.0",
- "concat-map": "0.0.1"
+ "engines": {
+ "node": "18 || 20 || >=22"
}
},
"node_modules/braces": {
@@ -5586,9 +5795,9 @@
}
},
"node_modules/browserslist": {
- "version": "4.24.4",
- "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.4.tgz",
- "integrity": "sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==",
+ "version": "4.28.1",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
+ "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
"dev": true,
"funding": [
{
@@ -5606,10 +5815,11 @@
],
"license": "MIT",
"dependencies": {
- "caniuse-lite": "^1.0.30001688",
- "electron-to-chromium": "^1.5.73",
- "node-releases": "^2.0.19",
- "update-browserslist-db": "^1.1.1"
+ "baseline-browser-mapping": "^2.9.0",
+ "caniuse-lite": "^1.0.30001759",
+ "electron-to-chromium": "^1.5.263",
+ "node-releases": "^2.0.27",
+ "update-browserslist-db": "^1.2.0"
},
"bin": {
"browserslist": "cli.js"
@@ -5618,13 +5828,6 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
- "node_modules/buffer-builder": {
- "version": "0.2.0",
- "resolved": "https://registry.npmjs.org/buffer-builder/-/buffer-builder-0.2.0.tgz",
- "integrity": "sha512-7VPMEPuYznPSoR21NE1zvd2Xna6c/CloiZCfcMXR1Jny6PjX0N4Nsa38zcBFo/FMK+BlA+FLKbJCQ0i2yxp+Xg==",
- "dev": true,
- "license": "MIT/X11"
- },
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
@@ -5690,15 +5893,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/callsites": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
- "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
- "dev": true,
- "engines": {
- "node": ">=6"
- }
- },
"node_modules/camelcase-css": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
@@ -5731,18 +5925,11 @@
"license": "CC-BY-4.0"
},
"node_modules/chai": {
- "version": "5.3.3",
- "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
- "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
+ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
"dev": true,
"license": "MIT",
- "dependencies": {
- "assertion-error": "^2.0.1",
- "check-error": "^2.1.1",
- "deep-eql": "^5.0.1",
- "loupe": "^3.1.0",
- "pathval": "^2.0.0"
- },
"engines": {
"node": ">=18"
}
@@ -5752,6 +5939,7 @@
"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"
@@ -5763,20 +5951,26 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
- "node_modules/check-error": {
- "version": "2.1.1",
- "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz",
- "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==",
+ "node_modules/chokidar": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
+ "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"dev": true,
"license": "MIT",
+ "dependencies": {
+ "readdirp": "^4.0.1"
+ },
"engines": {
- "node": ">= 16"
+ "node": ">= 14.16.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
}
},
"node_modules/ci-info": {
- "version": "3.9.0",
- "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz",
- "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==",
+ "version": "4.4.0",
+ "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz",
+ "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==",
"dev": true,
"funding": [
{
@@ -5784,6 +5978,7 @@
"url": "https://github.com/sponsors/sibiraj-s"
}
],
+ "license": "MIT",
"engines": {
"node": ">=8"
}
@@ -5901,6 +6096,7 @@
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+ "license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
@@ -5918,12 +6114,6 @@
"node": ">=4.0.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
- },
"node_modules/consola": {
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz",
@@ -5934,6 +6124,13 @@
"node": "^14.18.0 || >=16.10.0"
}
},
+ "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.0.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
@@ -5944,13 +6141,13 @@
}
},
"node_modules/core-js-compat": {
- "version": "3.41.0",
- "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.41.0.tgz",
- "integrity": "sha512-RFsU9LySVue9RTwdDVX/T0e2Y6jRYWXERKElIjpuEOEnxaXffI0X7RUwVzfYLfzuLXSNJDYoRYUAmRUcyln20A==",
+ "version": "3.48.0",
+ "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.48.0.tgz",
+ "integrity": "sha512-OM4cAF3D6VtH/WkLtWvyNC56EZVXsZdU3iqaMG2B4WvYrlqU831pc4UtG5yp0sE9z8Y02wVN7PjW5Zf9Gt0f1Q==",
"dev": true,
"license": "MIT",
"dependencies": {
- "browserslist": "^4.24.4"
+ "browserslist": "^4.28.1"
},
"funding": {
"type": "opencollective",
@@ -5982,6 +6179,20 @@
"node": ">=8"
}
},
+ "node_modules/css-tree": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
+ "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mdn-data": "2.27.1",
+ "source-map-js": "^1.2.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",
@@ -6002,17 +6213,29 @@
}
},
"node_modules/cssstyle": {
- "version": "4.2.1",
- "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.2.1.tgz",
- "integrity": "sha512-9+vem03dMXG7gDmZ62uqmRiMRNtinIZ9ZyuF6BdxzfOD+FdN5hretzynkn0ReS2DO2GSw76RWHs0UmJPI2zUjw==",
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.2.0.tgz",
+ "integrity": "sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@asamuzakjp/css-color": "^2.8.2",
- "rrweb-cssom": "^0.8.0"
+ "@asamuzakjp/css-color": "^5.0.1",
+ "@csstools/css-syntax-patches-for-csstree": "^1.0.28",
+ "css-tree": "^3.1.0",
+ "lru-cache": "^11.2.6"
},
"engines": {
- "node": ">=18"
+ "node": ">=20"
+ }
+ },
+ "node_modules/cssstyle/node_modules/lru-cache": {
+ "version": "11.2.7",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz",
+ "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
}
},
"node_modules/csstype": {
@@ -6026,6 +6249,7 @@
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"dev": true,
+ "license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
@@ -6038,6 +6262,7 @@
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"dev": true,
+ "license": "ISC",
"engines": {
"node": ">=12"
}
@@ -6047,15 +6272,17 @@
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"dev": true,
+ "license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
- "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
+ "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"dev": true,
+ "license": "ISC",
"engines": {
"node": ">=12"
}
@@ -6065,6 +6292,7 @@
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"dev": true,
+ "license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
@@ -6077,6 +6305,7 @@
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"dev": true,
+ "license": "ISC",
"engines": {
"node": ">=12"
}
@@ -6086,6 +6315,7 @@
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"dev": true,
+ "license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
@@ -6102,6 +6332,7 @@
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"dev": true,
+ "license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
@@ -6114,6 +6345,7 @@
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"dev": true,
+ "license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
@@ -6126,6 +6358,7 @@
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"dev": true,
+ "license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
@@ -6138,21 +6371,23 @@
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"dev": true,
+ "license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/data-urls": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
- "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==",
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
+ "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "whatwg-mimetype": "^4.0.0",
- "whatwg-url": "^14.0.0"
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^16.0.0"
},
"engines": {
- "node": ">=18"
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/data-view-buffer": {
@@ -6226,16 +6461,18 @@
}
},
"node_modules/decimal.js": {
- "version": "10.4.3",
- "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz",
- "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==",
- "dev": true
+ "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/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
- "dev": true
+ "dev": true,
+ "license": "MIT"
},
"node_modules/decode-bmp": {
"version": "0.2.1",
@@ -6266,16 +6503,6 @@
"node": ">=8.6"
}
},
- "node_modules/deep-eql": {
- "version": "5.0.2",
- "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
- "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6"
- }
- },
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -6339,6 +6566,7 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+ "license": "MIT",
"engines": {
"node": ">=0.4.0"
}
@@ -6353,9 +6581,9 @@
}
},
"node_modules/detect-libc": {
- "version": "2.0.3",
- "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz",
- "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==",
+ "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": {
@@ -6368,45 +6596,11 @@
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
"license": "MIT"
},
- "node_modules/diff-sequences": {
- "version": "29.6.3",
- "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz",
- "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==",
- "dev": true,
- "engines": {
- "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
- }
- },
- "node_modules/dir-glob": {
- "version": "3.0.1",
- "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
- "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==",
- "dev": true,
- "dependencies": {
- "path-type": "^4.0.0"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/doctrine": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
- "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==",
- "dev": true,
- "dependencies": {
- "esutils": "^2.0.2"
- },
- "engines": {
- "node": ">=6.0.0"
- }
- },
"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,
- "peer": true
+ "dev": true
},
"node_modules/dom-helpers": {
"version": "5.2.1",
@@ -6432,13 +6626,6 @@
"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",
@@ -6456,9 +6643,9 @@
}
},
"node_modules/electron-to-chromium": {
- "version": "1.5.129",
- "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.129.tgz",
- "integrity": "sha512-JlXUemX4s0+9f8mLqib/bHH8gOHf5elKS6KeWG3sk3xozb/JTq/RLXIv8OKUWiK4Ah00Wm88EFj5PYkFr4RUPA==",
+ "version": "1.5.307",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.307.tgz",
+ "integrity": "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==",
"dev": true,
"license": "ISC"
},
@@ -6470,30 +6657,54 @@
"license": "MIT"
},
"node_modules/engine.io-client": {
- "version": "6.5.4",
- "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.5.4.tgz",
- "integrity": "sha512-GeZeeRjpD2qf49cZQ0Wvh/8NJNfeXkXXcoGh+F77oEAgo9gUHwT1fCRxSNU+YEEaysOJTnsFHmM5oAcPy4ntvQ==",
+ "version": "6.6.4",
+ "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz",
+ "integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
- "debug": "~4.3.1",
+ "debug": "~4.4.1",
"engine.io-parser": "~5.2.1",
- "ws": "~8.17.1",
- "xmlhttprequest-ssl": "~2.0.0"
+ "ws": "~8.18.3",
+ "xmlhttprequest-ssl": "~2.1.1"
+ }
+ },
+ "node_modules/engine.io-client/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/engine.io-client/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/engine.io-parser": {
- "version": "5.2.2",
- "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.2.tgz",
- "integrity": "sha512-RcyUFKA93/CXH20l4SoVvzZfrSDMOTUS3bWVpTt2FuFP+XYrL8i8oonHP7WInRyVHXh0n/ORtoeiE1os+8qkSw==",
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
+ "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
+ "license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/entities": {
- "version": "4.5.0",
- "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
- "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+ "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": {
@@ -6504,9 +6715,9 @@
}
},
"node_modules/es-abstract": {
- "version": "1.23.9",
- "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.9.tgz",
- "integrity": "sha512-py07lI0wjxAC/DcfK1S6G7iANonniZwTISvdPzk9hzeH0IZIshbuuFxLIU96OyF89Yb9hiqWn8M/bY83KY5vzA==",
+ "version": "1.24.1",
+ "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz",
+ "integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -6514,18 +6725,18 @@
"arraybuffer.prototype.slice": "^1.0.4",
"available-typed-arrays": "^1.0.7",
"call-bind": "^1.0.8",
- "call-bound": "^1.0.3",
+ "call-bound": "^1.0.4",
"data-view-buffer": "^1.0.2",
"data-view-byte-length": "^1.0.2",
"data-view-byte-offset": "^1.0.1",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
- "es-object-atoms": "^1.0.0",
+ "es-object-atoms": "^1.1.1",
"es-set-tostringtag": "^2.1.0",
"es-to-primitive": "^1.3.0",
"function.prototype.name": "^1.1.8",
- "get-intrinsic": "^1.2.7",
- "get-proto": "^1.0.0",
+ "get-intrinsic": "^1.3.0",
+ "get-proto": "^1.0.1",
"get-symbol-description": "^1.1.0",
"globalthis": "^1.0.4",
"gopd": "^1.2.0",
@@ -6537,21 +6748,24 @@
"is-array-buffer": "^3.0.5",
"is-callable": "^1.2.7",
"is-data-view": "^1.0.2",
+ "is-negative-zero": "^2.0.3",
"is-regex": "^1.2.1",
+ "is-set": "^2.0.3",
"is-shared-array-buffer": "^1.0.4",
"is-string": "^1.1.1",
"is-typed-array": "^1.1.15",
- "is-weakref": "^1.1.0",
+ "is-weakref": "^1.1.1",
"math-intrinsics": "^1.1.0",
- "object-inspect": "^1.13.3",
+ "object-inspect": "^1.13.4",
"object-keys": "^1.1.1",
"object.assign": "^4.1.7",
"own-keys": "^1.0.1",
- "regexp.prototype.flags": "^1.5.3",
+ "regexp.prototype.flags": "^1.5.4",
"safe-array-concat": "^1.1.3",
"safe-push-apply": "^1.0.0",
"safe-regex-test": "^1.1.0",
"set-proto": "^1.0.0",
+ "stop-iteration-iterator": "^1.1.0",
"string.prototype.trim": "^1.2.10",
"string.prototype.trimend": "^1.0.9",
"string.prototype.trimstart": "^1.0.8",
@@ -6560,7 +6774,7 @@
"typed-array-byte-offset": "^1.0.4",
"typed-array-length": "^1.0.7",
"unbox-primitive": "^1.1.0",
- "which-typed-array": "^1.1.18"
+ "which-typed-array": "^1.1.19"
},
"engines": {
"node": ">= 0.4"
@@ -6588,9 +6802,9 @@
}
},
"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==",
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
+ "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==",
"dev": true,
"license": "MIT"
},
@@ -6639,10 +6853,21 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/es-toolkit": {
+ "version": "1.45.1",
+ "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz",
+ "integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==",
+ "dev": true,
+ "license": "MIT",
+ "workspaces": [
+ "docs",
+ "benchmarks"
+ ]
+ },
"node_modules/esbuild": {
- "version": "0.25.2",
- "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.2.tgz",
- "integrity": "sha512-16854zccKPnC+toMywC+uKNeYSv+/eXkevRAfwRD/G9Cleq66m8XFIrigkbvauLLlCfDL45Q2cWegSg53gGBnQ==",
+ "version": "0.27.4",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz",
+ "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@@ -6653,31 +6878,32 @@
"node": ">=18"
},
"optionalDependencies": {
- "@esbuild/aix-ppc64": "0.25.2",
- "@esbuild/android-arm": "0.25.2",
- "@esbuild/android-arm64": "0.25.2",
- "@esbuild/android-x64": "0.25.2",
- "@esbuild/darwin-arm64": "0.25.2",
- "@esbuild/darwin-x64": "0.25.2",
- "@esbuild/freebsd-arm64": "0.25.2",
- "@esbuild/freebsd-x64": "0.25.2",
- "@esbuild/linux-arm": "0.25.2",
- "@esbuild/linux-arm64": "0.25.2",
- "@esbuild/linux-ia32": "0.25.2",
- "@esbuild/linux-loong64": "0.25.2",
- "@esbuild/linux-mips64el": "0.25.2",
- "@esbuild/linux-ppc64": "0.25.2",
- "@esbuild/linux-riscv64": "0.25.2",
- "@esbuild/linux-s390x": "0.25.2",
- "@esbuild/linux-x64": "0.25.2",
- "@esbuild/netbsd-arm64": "0.25.2",
- "@esbuild/netbsd-x64": "0.25.2",
- "@esbuild/openbsd-arm64": "0.25.2",
- "@esbuild/openbsd-x64": "0.25.2",
- "@esbuild/sunos-x64": "0.25.2",
- "@esbuild/win32-arm64": "0.25.2",
- "@esbuild/win32-ia32": "0.25.2",
- "@esbuild/win32-x64": "0.25.2"
+ "@esbuild/aix-ppc64": "0.27.4",
+ "@esbuild/android-arm": "0.27.4",
+ "@esbuild/android-arm64": "0.27.4",
+ "@esbuild/android-x64": "0.27.4",
+ "@esbuild/darwin-arm64": "0.27.4",
+ "@esbuild/darwin-x64": "0.27.4",
+ "@esbuild/freebsd-arm64": "0.27.4",
+ "@esbuild/freebsd-x64": "0.27.4",
+ "@esbuild/linux-arm": "0.27.4",
+ "@esbuild/linux-arm64": "0.27.4",
+ "@esbuild/linux-ia32": "0.27.4",
+ "@esbuild/linux-loong64": "0.27.4",
+ "@esbuild/linux-mips64el": "0.27.4",
+ "@esbuild/linux-ppc64": "0.27.4",
+ "@esbuild/linux-riscv64": "0.27.4",
+ "@esbuild/linux-s390x": "0.27.4",
+ "@esbuild/linux-x64": "0.27.4",
+ "@esbuild/netbsd-arm64": "0.27.4",
+ "@esbuild/netbsd-x64": "0.27.4",
+ "@esbuild/openbsd-arm64": "0.27.4",
+ "@esbuild/openbsd-x64": "0.27.4",
+ "@esbuild/openharmony-arm64": "0.27.4",
+ "@esbuild/sunos-x64": "0.27.4",
+ "@esbuild/win32-arm64": "0.27.4",
+ "@esbuild/win32-ia32": "0.27.4",
+ "@esbuild/win32-x64": "0.27.4"
}
},
"node_modules/escalade": {
@@ -6703,86 +6929,95 @@
}
},
"node_modules/eslint": {
- "version": "8.57.0",
- "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz",
- "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==",
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.1.0.tgz",
+ "integrity": "sha512-S9jlY/ELKEUwwQnqWDO+f+m6sercqOPSqXM5Go94l7DOmxHVDgmSFGWEzeE/gwgTAr0W103BWt0QLe/7mabIvA==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@eslint-community/eslint-utils": "^4.2.0",
- "@eslint-community/regexpp": "^4.6.1",
- "@eslint/eslintrc": "^2.1.4",
- "@eslint/js": "8.57.0",
- "@humanwhocodes/config-array": "^0.11.14",
+ "@eslint-community/eslint-utils": "^4.8.0",
+ "@eslint-community/regexpp": "^4.12.2",
+ "@eslint/config-array": "^0.23.3",
+ "@eslint/config-helpers": "^0.5.3",
+ "@eslint/core": "^1.1.1",
+ "@eslint/plugin-kit": "^0.6.1",
+ "@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1",
- "@nodelib/fs.walk": "^1.2.8",
- "@ungap/structured-clone": "^1.2.0",
- "ajv": "^6.12.4",
- "chalk": "^4.0.0",
- "cross-spawn": "^7.0.2",
+ "@humanwhocodes/retry": "^0.4.2",
+ "@types/estree": "^1.0.6",
+ "ajv": "^6.14.0",
+ "cross-spawn": "^7.0.6",
"debug": "^4.3.2",
- "doctrine": "^3.0.0",
"escape-string-regexp": "^4.0.0",
- "eslint-scope": "^7.2.2",
- "eslint-visitor-keys": "^3.4.3",
- "espree": "^9.6.1",
- "esquery": "^1.4.2",
+ "eslint-scope": "^9.1.2",
+ "eslint-visitor-keys": "^5.0.1",
+ "espree": "^11.2.0",
+ "esquery": "^1.7.0",
"esutils": "^2.0.2",
"fast-deep-equal": "^3.1.3",
- "file-entry-cache": "^6.0.1",
+ "file-entry-cache": "^8.0.0",
"find-up": "^5.0.0",
"glob-parent": "^6.0.2",
- "globals": "^13.19.0",
- "graphemer": "^1.4.0",
"ignore": "^5.2.0",
"imurmurhash": "^0.1.4",
"is-glob": "^4.0.0",
- "is-path-inside": "^3.0.3",
- "js-yaml": "^4.1.0",
"json-stable-stringify-without-jsonify": "^1.0.1",
- "levn": "^0.4.1",
- "lodash.merge": "^4.6.2",
- "minimatch": "^3.1.2",
+ "minimatch": "^10.2.4",
"natural-compare": "^1.4.0",
- "optionator": "^0.9.3",
- "strip-ansi": "^6.0.1",
- "text-table": "^0.2.0"
+ "optionator": "^0.9.3"
},
"bin": {
"eslint": "bin/eslint.js"
},
"engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ "node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
- "url": "https://opencollective.com/eslint"
+ "url": "https://eslint.org/donate"
+ },
+ "peerDependencies": {
+ "jiti": "*"
+ },
+ "peerDependenciesMeta": {
+ "jiti": {
+ "optional": true
+ }
}
},
"node_modules/eslint-plugin-react-hooks": {
- "version": "4.6.0",
- "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.0.tgz",
- "integrity": "sha512-oFc7Itz9Qxh2x4gNHStv3BqJq54ExXmfC+a1NjAta66IAN87Wu0R/QArgIS9qKzX3dXKPI9H5crl9QchNMY9+g==",
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz",
+ "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==",
"dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.24.4",
+ "@babel/parser": "^7.24.4",
+ "hermes-parser": "^0.25.1",
+ "zod": "^3.25.0 || ^4.0.0",
+ "zod-validation-error": "^3.5.0 || ^4.0.0"
+ },
"engines": {
- "node": ">=10"
+ "node": ">=18"
},
"peerDependencies": {
- "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0"
+ "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0"
}
},
"node_modules/eslint-plugin-react-refresh": {
- "version": "0.4.7",
- "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.7.tgz",
- "integrity": "sha512-yrj+KInFmwuQS2UQcg1SF83ha1tuHC1jMQbRNyuWtlEzzKRDgAl7L4Yp4NlDUZTZNlWvHEzOtJhMi40R7JxcSw==",
+ "version": "0.5.2",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.2.tgz",
+ "integrity": "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==",
"dev": true,
"license": "MIT",
"peerDependencies": {
- "eslint": ">=7"
+ "eslint": "^9 || ^10"
}
},
"node_modules/eslint-plugin-simple-import-sort": {
- "version": "12.1.0",
- "resolved": "https://registry.npmjs.org/eslint-plugin-simple-import-sort/-/eslint-plugin-simple-import-sort-12.1.0.tgz",
- "integrity": "sha512-Y2fqAfC11TcG/WP3TrI1Gi3p3nc8XJyEOJYHyEPEGI/UAgNx6akxxlX74p7SbAQdLcgASKhj8M0GKvH3vq/+ig==",
+ "version": "12.1.1",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-simple-import-sort/-/eslint-plugin-simple-import-sort-12.1.1.tgz",
+ "integrity": "sha512-6nuzu4xwQtE3332Uz0to+TxDQYRLTKRESSc2hefVT48Zc8JthmN23Gx9lnYhu0FtkRSL1oxny3kJ2aveVhmOVA==",
"dev": true,
"license": "MIT",
"peerDependencies": {
@@ -6790,32 +7025,36 @@
}
},
"node_modules/eslint-plugin-testing-library": {
- "version": "6.2.0",
- "resolved": "https://registry.npmjs.org/eslint-plugin-testing-library/-/eslint-plugin-testing-library-6.2.0.tgz",
- "integrity": "sha512-+LCYJU81WF2yQ+Xu4A135CgK8IszcFcyMF4sWkbiu6Oj+Nel0TrkZq/HvDw0/1WuO3dhDQsZA/OpEMGd0NfcUw==",
+ "version": "7.16.2",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-testing-library/-/eslint-plugin-testing-library-7.16.2.tgz",
+ "integrity": "sha512-8gleGnQXK2ZA3hHwjCwpYTZvM+9VsrJ+/9kDI8CjqAQGAdMQOdn/rJNu7ZySENuiWlGKQWyZJ4ZjEg2zamaRHw==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@typescript-eslint/utils": "^5.58.0"
+ "@typescript-eslint/scope-manager": "^8.56.0",
+ "@typescript-eslint/utils": "^8.56.0"
},
"engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0",
- "npm": ">=6"
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"peerDependencies": {
- "eslint": "^7.5.0 || ^8.0.0"
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0"
}
},
"node_modules/eslint-scope": {
- "version": "7.2.2",
- "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz",
- "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==",
+ "version": "9.1.2",
+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz",
+ "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==",
"dev": true,
+ "license": "BSD-2-Clause",
"dependencies": {
+ "@types/esrecurse": "^4.3.1",
+ "@types/estree": "^1.0.8",
"esrecurse": "^4.3.0",
"estraverse": "^5.2.0"
},
"engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ "node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://opencollective.com/eslint"
@@ -6826,6 +7065,7 @@
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
"integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
"dev": true,
+ "license": "Apache-2.0",
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
},
@@ -6833,55 +7073,56 @@
"url": "https://opencollective.com/eslint"
}
},
- "node_modules/eslint/node_modules/globals": {
- "version": "13.24.0",
- "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz",
- "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==",
+ "node_modules/eslint/node_modules/eslint-visitor-keys": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
+ "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
"dev": true,
- "dependencies": {
- "type-fest": "^0.20.2"
- },
+ "license": "Apache-2.0",
"engines": {
- "node": ">=8"
+ "node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
- "url": "https://github.com/sponsors/sindresorhus"
+ "url": "https://opencollective.com/eslint"
}
},
- "node_modules/eslint/node_modules/type-fest": {
- "version": "0.20.2",
- "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
- "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
+ "node_modules/espree": {
+ "version": "11.2.0",
+ "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz",
+ "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==",
"dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "acorn": "^8.16.0",
+ "acorn-jsx": "^5.3.2",
+ "eslint-visitor-keys": "^5.0.1"
+ },
"engines": {
- "node": ">=10"
+ "node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
- "url": "https://github.com/sponsors/sindresorhus"
+ "url": "https://opencollective.com/eslint"
}
},
- "node_modules/espree": {
- "version": "9.6.1",
- "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz",
- "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==",
+ "node_modules/espree/node_modules/eslint-visitor-keys": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
+ "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
"dev": true,
- "dependencies": {
- "acorn": "^8.9.0",
- "acorn-jsx": "^5.3.2",
- "eslint-visitor-keys": "^3.4.1"
- },
+ "license": "Apache-2.0",
"engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ "node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/esquery": {
- "version": "1.5.0",
- "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz",
- "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==",
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
+ "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
"dev": true,
+ "license": "BSD-3-Clause",
"dependencies": {
"estraverse": "^5.1.0"
},
@@ -6894,6 +7135,7 @@
"resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
"integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
"dev": true,
+ "license": "BSD-2-Clause",
"dependencies": {
"estraverse": "^5.2.0"
},
@@ -6906,6 +7148,7 @@
"resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
"integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
"dev": true,
+ "license": "BSD-2-Clause",
"engines": {
"node": ">=4.0"
}
@@ -6930,54 +7173,34 @@
}
},
"node_modules/eventemitter3": {
- "version": "4.0.7",
- "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
- "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
- "dev": true
- },
- "node_modules/execa": {
- "version": "5.1.1",
- "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
- "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==",
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
+ "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"dev": true,
- "dependencies": {
- "cross-spawn": "^7.0.3",
- "get-stream": "^6.0.0",
- "human-signals": "^2.1.0",
- "is-stream": "^2.0.0",
- "merge-stream": "^2.0.0",
- "npm-run-path": "^4.0.1",
- "onetime": "^5.1.2",
- "signal-exit": "^3.0.3",
- "strip-final-newline": "^2.0.0"
- },
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sindresorhus/execa?sponsor=1"
- }
+ "license": "MIT"
},
"node_modules/expect": {
- "version": "29.7.0",
- "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz",
- "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==",
+ "version": "30.3.0",
+ "resolved": "https://registry.npmjs.org/expect/-/expect-30.3.0.tgz",
+ "integrity": "sha512-1zQrciTiQfRdo7qJM1uG4navm8DayFa2TgCSRlzUyNkhcJ6XUZF3hjnpkyr3VhAqPH7i/9GkG7Tv5abz6fqz0Q==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@jest/expect-utils": "^29.7.0",
- "jest-get-type": "^29.6.3",
- "jest-matcher-utils": "^29.7.0",
- "jest-message-util": "^29.7.0",
- "jest-util": "^29.7.0"
+ "@jest/expect-utils": "30.3.0",
+ "@jest/get-type": "30.1.0",
+ "jest-matcher-utils": "30.3.0",
+ "jest-message-util": "30.3.0",
+ "jest-mock": "30.3.0",
+ "jest-util": "30.3.0"
},
"engines": {
- "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/expect-type": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.1.tgz",
- "integrity": "sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw==",
+ "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": {
@@ -6989,44 +7212,6 @@
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
},
- "node_modules/fast-equals": {
- "version": "5.2.2",
- "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.2.2.tgz",
- "integrity": "sha512-V7/RktU11J3I36Nwq2JnZEM7tNm17eBJz+u25qdxBZeCKiX6BkVSZQjwWIr+IobgnZy+ag73tTZgZi7tr0LrBw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6.0.0"
- }
- },
- "node_modules/fast-glob": {
- "version": "3.3.2",
- "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz",
- "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==",
- "dev": true,
- "dependencies": {
- "@nodelib/fs.stat": "^2.0.2",
- "@nodelib/fs.walk": "^1.2.3",
- "glob-parent": "^5.1.2",
- "merge2": "^1.3.0",
- "micromatch": "^4.0.4"
- },
- "engines": {
- "node": ">=8.6.0"
- }
- },
- "node_modules/fast-glob/node_modules/glob-parent": {
- "version": "5.1.2",
- "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
- "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
- "dev": true,
- "dependencies": {
- "is-glob": "^4.0.1"
- },
- "engines": {
- "node": ">= 6"
- }
- },
"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",
@@ -7040,9 +7225,9 @@
"dev": true
},
"node_modules/fast-uri": {
- "version": "3.0.6",
- "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz",
- "integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==",
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
+ "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
"dev": true,
"funding": [
{
@@ -7053,17 +7238,8 @@
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
- ],
- "license": "BSD-3-Clause"
- },
- "node_modules/fastq": {
- "version": "1.17.1",
- "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz",
- "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==",
- "dev": true,
- "dependencies": {
- "reusify": "^1.0.4"
- }
+ ],
+ "license": "BSD-3-Clause"
},
"node_modules/fflate": {
"version": "0.8.2",
@@ -7073,15 +7249,16 @@
"license": "MIT"
},
"node_modules/file-entry-cache": {
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
- "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==",
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
+ "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "flat-cache": "^3.0.4"
+ "flat-cache": "^4.0.0"
},
"engines": {
- "node": "^10.12.0 || >=12.0.0"
+ "node": ">=16.0.0"
}
},
"node_modules/file-selector": {
@@ -7097,9 +7274,9 @@
}
},
"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==",
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz",
+ "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@@ -7117,9 +7294,9 @@
}
},
"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==",
+ "version": "5.1.9",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
+ "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
"dev": true,
"license": "ISC",
"dependencies": {
@@ -7158,36 +7335,37 @@
}
},
"node_modules/flat-cache": {
- "version": "3.2.0",
- "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz",
- "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==",
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
+ "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"flatted": "^3.2.9",
- "keyv": "^4.5.3",
- "rimraf": "^3.0.2"
+ "keyv": "^4.5.4"
},
"engines": {
- "node": "^10.12.0 || >=12.0.0"
+ "node": ">=16"
}
},
"node_modules/flatted": {
- "version": "3.3.3",
- "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
- "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
+ "version": "3.4.2",
+ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
+ "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"dev": true,
"license": "ISC"
},
"node_modules/follow-redirects": {
- "version": "1.15.6",
- "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz",
- "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==",
+ "version": "1.15.11",
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
+ "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
+ "license": "MIT",
"engines": {
"node": ">=4.0"
},
@@ -7244,9 +7422,9 @@
}
},
"node_modules/form-data": {
- "version": "4.0.4",
- "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
- "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
+ "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
@@ -7259,11 +7437,31 @@
"node": ">= 6"
}
},
- "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
+ "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/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==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10.0.0"
+ }
},
"node_modules/fsevents": {
"version": "2.3.3",
@@ -7319,6 +7517,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/generator-function": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz",
+ "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/gensync": {
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
@@ -7391,18 +7599,6 @@
"node": ">= 0.4"
}
},
- "node_modules/get-stream": {
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
- "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==",
- "dev": true,
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
"node_modules/get-symbol-description": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz",
@@ -7421,26 +7617,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/glob": {
- "version": "7.2.3",
- "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
- "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
- "dev": true,
- "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-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -7454,12 +7630,16 @@
}
},
"node_modules/globals": {
- "version": "11.12.0",
- "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz",
- "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==",
+ "version": "17.4.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-17.4.0.tgz",
+ "integrity": "sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==",
"dev": true,
+ "license": "MIT",
"engines": {
- "node": ">=4"
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/globalthis": {
@@ -7479,26 +7659,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/globby": {
- "version": "11.1.0",
- "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz",
- "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==",
- "dev": true,
- "dependencies": {
- "array-union": "^2.1.0",
- "dir-glob": "^3.0.1",
- "fast-glob": "^3.2.9",
- "ignore": "^5.2.0",
- "merge2": "^1.4.1",
- "slash": "^3.0.0"
- },
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -7517,16 +7677,10 @@
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"dev": true
},
- "node_modules/graphemer": {
- "version": "1.4.0",
- "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
- "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==",
- "dev": true
- },
"node_modules/graphql": {
- "version": "16.10.0",
- "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.10.0.tgz",
- "integrity": "sha512-AjqGKbDGUFRKIRCP9tCKiIGHyriz2oHEbPIbEtcSLSs4YjReZOIPQQWek4+6hjw62H9QShXHyaGivGiYVLeYFQ==",
+ "version": "16.13.2",
+ "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.13.2.tgz",
+ "integrity": "sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==",
"dev": true,
"license": "MIT",
"engines": {
@@ -7630,23 +7784,42 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/hermes-estree": {
+ "version": "0.25.1",
+ "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
+ "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/hermes-parser": {
+ "version": "0.25.1",
+ "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz",
+ "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hermes-estree": "0.25.1"
+ }
+ },
"node_modules/html-encoding-sniffer": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
- "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==",
+ "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": {
- "whatwg-encoding": "^3.1.1"
+ "@exodus/bytes": "^1.6.0"
},
"engines": {
- "node": ">=18"
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
- "dev": true
+ "dev": true,
+ "license": "MIT"
},
"node_modules/http-proxy-agent": {
"version": "7.0.2",
@@ -7675,22 +7848,14 @@
"node": ">= 14"
}
},
- "node_modules/human-signals": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
- "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==",
- "dev": true,
- "engines": {
- "node": ">=10.17.0"
- }
- },
"node_modules/husky": {
- "version": "9.0.11",
- "resolved": "https://registry.npmjs.org/husky/-/husky-9.0.11.tgz",
- "integrity": "sha512-AB6lFlbwwyIqMdHYhwPe+kjOC3Oc5P3nThEoW/AaO2BX3vJDjWPFxYLxokUZOo6RNX20He3AaT8sESs9NJcmEw==",
+ "version": "9.1.7",
+ "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz",
+ "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==",
"dev": true,
+ "license": "MIT",
"bin": {
- "husky": "bin.mjs"
+ "husky": "bin.js"
},
"engines": {
"node": ">=18"
@@ -7706,18 +7871,6 @@
"dev": true,
"license": "MPL-2.0"
},
- "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==",
- "dev": true,
- "dependencies": {
- "safer-buffer": ">= 2.1.2 < 3.0.0"
- },
- "engines": {
- "node": ">=0.10.0"
- }
- },
"node_modules/idb": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
@@ -7734,22 +7887,24 @@
"node": ">= 4"
}
},
- "node_modules/import-fresh": {
- "version": "3.3.0",
- "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
- "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==",
+ "node_modules/immer": {
+ "version": "10.2.0",
+ "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
+ "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
"dev": true,
- "dependencies": {
- "parent-module": "^1.0.0",
- "resolve-from": "^4.0.0"
- },
- "engines": {
- "node": ">=6"
- },
+ "license": "MIT",
"funding": {
- "url": "https://github.com/sponsors/sindresorhus"
+ "type": "opencollective",
+ "url": "https://opencollective.com/immer"
}
},
+ "node_modules/immutable": {
+ "version": "5.1.5",
+ "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz",
+ "integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/imurmurhash": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
@@ -7768,22 +7923,6 @@
"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==",
- "dev": true,
- "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
- },
"node_modules/internal-slot": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
@@ -7804,6 +7943,7 @@
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"dev": true,
+ "license": "ISC",
"engines": {
"node": ">=12"
}
@@ -7979,14 +8119,15 @@
}
},
"node_modules/is-generator-function": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz",
- "integrity": "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==",
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz",
+ "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "call-bound": "^1.0.3",
- "get-proto": "^1.0.0",
+ "call-bound": "^1.0.4",
+ "generator-function": "^2.0.0",
+ "get-proto": "^1.0.1",
"has-tostringtag": "^1.0.2",
"safe-regex-test": "^1.1.0"
},
@@ -8029,6 +8170,19 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/is-negative-zero": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz",
+ "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/is-node-process": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz",
@@ -8072,15 +8226,6 @@
"node": ">=0.10.0"
}
},
- "node_modules/is-path-inside": {
- "version": "3.0.3",
- "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz",
- "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==",
- "dev": true,
- "engines": {
- "node": ">=8"
- }
- },
"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",
@@ -8272,6 +8417,7 @@
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
"integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
"dev": true,
+ "license": "BSD-3-Clause",
"engines": {
"node": ">=8"
}
@@ -8281,6 +8427,7 @@
"resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
"integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
"dev": true,
+ "license": "BSD-3-Clause",
"dependencies": {
"istanbul-lib-coverage": "^3.0.0",
"make-dir": "^4.0.0",
@@ -8290,26 +8437,12 @@
"node": ">=10"
}
},
- "node_modules/istanbul-lib-source-maps": {
- "version": "5.0.6",
- "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz",
- "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==",
- "dev": true,
- "license": "BSD-3-Clause",
- "dependencies": {
- "@jridgewell/trace-mapping": "^0.3.23",
- "debug": "^4.1.1",
- "istanbul-lib-coverage": "^3.0.0"
- },
- "engines": {
- "node": ">=10"
- }
- },
"node_modules/istanbul-reports": {
- "version": "3.1.7",
- "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.7.tgz",
- "integrity": "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==",
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
+ "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
"dev": true,
+ "license": "BSD-3-Clause",
"dependencies": {
"html-escaper": "^2.0.0",
"istanbul-lib-report": "^3.0.0"
@@ -8318,33 +8451,16 @@
"node": ">=8"
}
},
- "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.2",
- "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.2.tgz",
- "integrity": "sha512-2P4SQ0HrLQ+fw6llpLnOaGAvN2Zu6778SJMrCUwns4fOoG9ayrTiZk3VV8sCPkVZF8ab0zksVpS8FDY5pRCNBA==",
+ "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.3",
- "chalk": "^4.0.2",
+ "async": "^3.2.6",
"filelist": "^1.0.4",
- "minimatch": "^3.1.2"
+ "picocolors": "^1.1.1"
},
"bin": {
"jake": "bin/cli.js"
@@ -8354,18 +8470,19 @@
}
},
"node_modules/jest-diff": {
- "version": "29.7.0",
- "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz",
- "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==",
+ "version": "30.3.0",
+ "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.3.0.tgz",
+ "integrity": "sha512-n3q4PDQjS4LrKxfWB3Z5KNk1XjXtZTBwQp71OP0Jo03Z6V60x++K5L8k6ZrW8MY8pOFylZvHM0zsjS1RqlHJZQ==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "chalk": "^4.0.0",
- "diff-sequences": "^29.6.3",
- "jest-get-type": "^29.6.3",
- "pretty-format": "^29.7.0"
+ "@jest/diff-sequences": "30.3.0",
+ "@jest/get-type": "30.1.0",
+ "chalk": "^4.1.2",
+ "pretty-format": "30.3.0"
},
"engines": {
- "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/jest-diff/node_modules/ansi-styles": {
@@ -8373,6 +8490,7 @@
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
+ "license": "MIT",
"engines": {
"node": ">=10"
},
@@ -8381,47 +8499,41 @@
}
},
"node_modules/jest-diff/node_modules/pretty-format": {
- "version": "29.7.0",
- "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz",
- "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==",
+ "version": "30.3.0",
+ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz",
+ "integrity": "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@jest/schemas": "^29.6.3",
- "ansi-styles": "^5.0.0",
- "react-is": "^18.0.0"
+ "@jest/schemas": "30.0.5",
+ "ansi-styles": "^5.2.0",
+ "react-is": "^18.3.1"
},
"engines": {
- "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/jest-diff/node_modules/react-is": {
- "version": "18.2.0",
- "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz",
- "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==",
- "dev": true
- },
- "node_modules/jest-get-type": {
- "version": "29.6.3",
- "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz",
- "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==",
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
+ "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"dev": true,
- "engines": {
- "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
- }
+ "license": "MIT"
},
"node_modules/jest-matcher-utils": {
- "version": "29.7.0",
- "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz",
- "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==",
+ "version": "30.3.0",
+ "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.3.0.tgz",
+ "integrity": "sha512-HEtc9uFQgaUHkC7nLSlQL3Tph4Pjxt/yiPvkIrrDCt9jhoLIgxaubo1G+CFOnmHYMxHwwdaSN7mkIFs6ZK8OhA==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "chalk": "^4.0.0",
- "jest-diff": "^29.7.0",
- "jest-get-type": "^29.6.3",
- "pretty-format": "^29.7.0"
+ "@jest/get-type": "30.1.0",
+ "chalk": "^4.1.2",
+ "jest-diff": "30.3.0",
+ "pretty-format": "30.3.0"
},
"engines": {
- "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/jest-matcher-utils/node_modules/ansi-styles": {
@@ -8429,6 +8541,7 @@
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
+ "license": "MIT",
"engines": {
"node": ">=10"
},
@@ -8437,92 +8550,150 @@
}
},
"node_modules/jest-matcher-utils/node_modules/pretty-format": {
- "version": "29.7.0",
- "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz",
- "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==",
+ "version": "30.3.0",
+ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz",
+ "integrity": "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@jest/schemas": "^29.6.3",
- "ansi-styles": "^5.0.0",
- "react-is": "^18.0.0"
+ "@jest/schemas": "30.0.5",
+ "ansi-styles": "^5.2.0",
+ "react-is": "^18.3.1"
},
"engines": {
- "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/jest-matcher-utils/node_modules/react-is": {
- "version": "18.2.0",
- "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz",
- "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==",
- "dev": true
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
+ "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
+ "dev": true,
+ "license": "MIT"
},
"node_modules/jest-message-util": {
- "version": "29.7.0",
- "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz",
- "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==",
- "dev": true,
- "dependencies": {
- "@babel/code-frame": "^7.12.13",
- "@jest/types": "^29.6.3",
- "@types/stack-utils": "^2.0.0",
- "chalk": "^4.0.0",
- "graceful-fs": "^4.2.9",
- "micromatch": "^4.0.4",
- "pretty-format": "^29.7.0",
+ "version": "30.3.0",
+ "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.3.0.tgz",
+ "integrity": "sha512-Z/j4Bo+4ySJ+JPJN3b2Qbl9hDq3VrXmnjjGEWD/x0BCXeOXPTV1iZYYzl2X8c1MaCOL+ewMyNBcm88sboE6YWw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.27.1",
+ "@jest/types": "30.3.0",
+ "@types/stack-utils": "^2.0.3",
+ "chalk": "^4.1.2",
+ "graceful-fs": "^4.2.11",
+ "picomatch": "^4.0.3",
+ "pretty-format": "30.3.0",
"slash": "^3.0.0",
- "stack-utils": "^2.0.3"
+ "stack-utils": "^2.0.6"
+ },
+ "engines": {
+ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
+ }
+ },
+ "node_modules/jest-message-util/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",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/jest-message-util/node_modules/picomatch": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/jest-message-util/node_modules/pretty-format": {
+ "version": "30.3.0",
+ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz",
+ "integrity": "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jest/schemas": "30.0.5",
+ "ansi-styles": "^5.2.0",
+ "react-is": "^18.3.1"
+ },
+ "engines": {
+ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
+ }
+ },
+ "node_modules/jest-message-util/node_modules/react-is": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
+ "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/jest-mock": {
+ "version": "30.3.0",
+ "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.3.0.tgz",
+ "integrity": "sha512-OTzICK8CpE+t4ndhKrwlIdbM6Pn8j00lvmSmq5ejiO+KxukbLjgOflKWMn3KE34EZdQm5RqTuKj+5RIEniYhog==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jest/types": "30.3.0",
+ "@types/node": "*",
+ "jest-util": "30.3.0"
},
"engines": {
- "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
- "node_modules/jest-message-util/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==",
+ "node_modules/jest-regex-util": {
+ "version": "30.0.1",
+ "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.0.1.tgz",
+ "integrity": "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==",
"dev": true,
+ "license": "MIT",
"engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
- "node_modules/jest-message-util/node_modules/pretty-format": {
- "version": "29.7.0",
- "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz",
- "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==",
+ "node_modules/jest-util": {
+ "version": "30.3.0",
+ "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.3.0.tgz",
+ "integrity": "sha512-/jZDa00a3Sz7rdyu55NLrQCIrbyIkbBxareejQI315f/i8HjYN+ZWsDLLpoQSiUIEIyZF/R8fDg3BmB8AtHttg==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@jest/schemas": "^29.6.3",
- "ansi-styles": "^5.0.0",
- "react-is": "^18.0.0"
+ "@jest/types": "30.3.0",
+ "@types/node": "*",
+ "chalk": "^4.1.2",
+ "ci-info": "^4.2.0",
+ "graceful-fs": "^4.2.11",
+ "picomatch": "^4.0.3"
},
"engines": {
- "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
- "node_modules/jest-message-util/node_modules/react-is": {
- "version": "18.2.0",
- "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz",
- "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==",
- "dev": true
- },
- "node_modules/jest-util": {
- "version": "29.7.0",
- "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz",
- "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==",
+ "node_modules/jest-util/node_modules/picomatch": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
- "dependencies": {
- "@jest/types": "^29.6.3",
- "@types/node": "*",
- "chalk": "^4.0.0",
- "ci-info": "^3.2.0",
- "graceful-fs": "^4.2.9",
- "picomatch": "^2.2.3"
- },
+ "license": "MIT",
"engines": {
- "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/jiti": {
@@ -8540,50 +8711,37 @@
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
},
- "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==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "argparse": "^2.0.1"
- },
- "bin": {
- "js-yaml": "bin/js-yaml.js"
- }
- },
"node_modules/jsdom": {
- "version": "26.0.0",
- "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.0.0.tgz",
- "integrity": "sha512-BZYDGVAIriBWTpIxYzrXjv3E/4u8+/pSG5bQdIYCbNCGOvsPkDQfTVLAIXAf9ETdCpduCVTkDe2NNZ8NIwUVzw==",
+ "version": "28.1.0",
+ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz",
+ "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==",
"dev": true,
"license": "MIT",
"dependencies": {
- "cssstyle": "^4.2.1",
- "data-urls": "^5.0.0",
- "decimal.js": "^10.4.3",
- "form-data": "^4.0.1",
- "html-encoding-sniffer": "^4.0.0",
+ "@acemir/cssom": "^0.9.31",
+ "@asamuzakjp/dom-selector": "^6.8.1",
+ "@bramus/specificity": "^2.4.2",
+ "@exodus/bytes": "^1.11.0",
+ "cssstyle": "^6.0.1",
+ "data-urls": "^7.0.0",
+ "decimal.js": "^10.6.0",
+ "html-encoding-sniffer": "^6.0.0",
"http-proxy-agent": "^7.0.2",
"https-proxy-agent": "^7.0.6",
"is-potential-custom-element-name": "^1.0.1",
- "nwsapi": "^2.2.16",
- "parse5": "^7.2.1",
- "rrweb-cssom": "^0.8.0",
+ "parse5": "^8.0.0",
"saxes": "^6.0.0",
"symbol-tree": "^3.2.4",
- "tough-cookie": "^5.0.0",
+ "tough-cookie": "^6.0.0",
+ "undici": "^7.21.0",
"w3c-xmlserializer": "^5.0.0",
- "webidl-conversions": "^7.0.0",
- "whatwg-encoding": "^3.1.1",
- "whatwg-mimetype": "^4.0.0",
- "whatwg-url": "^14.1.0",
- "ws": "^8.18.0",
+ "webidl-conversions": "^8.0.1",
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^16.0.0",
"xml-name-validator": "^5.0.0"
},
"engines": {
- "node": ">=18"
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"peerDependencies": {
"canvas": "^3.0.0"
@@ -8594,28 +8752,6 @@
}
}
},
- "node_modules/jsdom/node_modules/ws": {
- "version": "8.18.0",
- "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz",
- "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==",
- "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",
@@ -8633,7 +8769,8 @@
"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
+ "dev": true,
+ "license": "MIT"
},
"node_modules/json-schema": {
"version": "0.4.0",
@@ -8646,7 +8783,8 @@
"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
+ "dev": true,
+ "license": "MIT"
},
"node_modules/json-stable-stringify-without-jsonify": {
"version": "1.0.1",
@@ -8667,10 +8805,11 @@
}
},
"node_modules/jsonfile": {
- "version": "6.1.0",
- "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz",
- "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==",
+ "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"
},
@@ -8703,6 +8842,7 @@
"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"
}
@@ -8754,10 +8894,11 @@
}
},
"node_modules/lodash": {
- "version": "4.17.21",
- "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
- "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
- "dev": true
+ "version": "4.17.23",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
+ "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
+ "dev": true,
+ "license": "MIT"
},
"node_modules/lodash.debounce": {
"version": "4.0.8",
@@ -8766,12 +8907,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/lodash.merge": {
- "version": "4.6.2",
- "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
- "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
- "dev": true
- },
"node_modules/lodash.sortby": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz",
@@ -8790,13 +8925,6 @@
"loose-envify": "cli.js"
}
},
- "node_modules/loupe": {
- "version": "3.2.1",
- "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
- "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -8811,31 +8939,30 @@
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true,
- "peer": true,
"bin": {
"lz-string": "bin/bin.js"
}
},
"node_modules/magic-string": {
- "version": "0.30.17",
- "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz",
- "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==",
+ "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.0"
+ "@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/magicast": {
- "version": "0.3.5",
- "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz",
- "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==",
+ "version": "0.5.2",
+ "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz",
+ "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/parser": "^7.25.4",
- "@babel/types": "^7.25.4",
- "source-map-js": "^1.2.0"
+ "@babel/parser": "^7.29.0",
+ "@babel/types": "^7.29.0",
+ "source-map-js": "^1.2.1"
}
},
"node_modules/make-dir": {
@@ -8843,6 +8970,7 @@
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
"integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"semver": "^7.5.3"
},
@@ -8853,26 +8981,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/make-dir/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,
- "dependencies": {
- "yallist": "^4.0.0"
- },
- "engines": {
- "node": ">=10"
- }
- },
"node_modules/make-dir/node_modules/semver": {
- "version": "7.6.0",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz",
- "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==",
+ "version": "7.7.4",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
+ "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"dev": true,
- "dependencies": {
- "lru-cache": "^6.0.0"
- },
+ "license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
@@ -8880,12 +8994,6 @@
"node": ">=10"
}
},
- "node_modules/make-dir/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
- },
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -8895,39 +9003,18 @@
"node": ">= 0.4"
}
},
- "node_modules/merge-stream": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
- "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==",
- "dev": true
- },
- "node_modules/merge2": {
- "version": "1.4.1",
- "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
- "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
- "dev": true,
- "engines": {
- "node": ">= 8"
- }
- },
- "node_modules/micromatch": {
- "version": "4.0.8",
- "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
- "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
+ "node_modules/mdn-data": {
+ "version": "2.27.1",
+ "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
+ "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
"dev": true,
- "license": "MIT",
- "dependencies": {
- "braces": "^3.0.3",
- "picomatch": "^2.3.1"
- },
- "engines": {
- "node": ">=8.6"
- }
+ "license": "CC0-1.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==",
+ "license": "MIT",
"engines": {
"node": ">= 0.6"
}
@@ -8936,6 +9023,7 @@
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
@@ -8943,15 +9031,6 @@
"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,
- "engines": {
- "node": ">=6"
- }
- },
"node_modules/min-indent": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
@@ -8962,15 +9041,19 @@
}
},
"node_modules/minimatch": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
- "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "version": "10.2.4",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
+ "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
"dev": true,
+ "license": "BlueOak-1.0.0",
"dependencies": {
- "brace-expansion": "^1.1.7"
+ "brace-expansion": "^5.0.2"
},
"engines": {
- "node": "*"
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/minipass": {
@@ -9008,30 +9091,30 @@
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
},
"node_modules/msw": {
- "version": "2.7.0",
- "resolved": "https://registry.npmjs.org/msw/-/msw-2.7.0.tgz",
- "integrity": "sha512-BIodwZ19RWfCbYTxWTUfTXc+sg4OwjCAgxU1ZsgmggX/7S3LdUifsbUPJs61j0rWb19CZRGY5if77duhc0uXzw==",
+ "version": "2.12.14",
+ "resolved": "https://registry.npmjs.org/msw/-/msw-2.12.14.tgz",
+ "integrity": "sha512-4KXa4nVBIBjbDbd7vfQNuQ25eFxug0aropCQFoI0JdOBuJWamkT1yLVIWReFI8SiTRc+H1hKzaNk+cLk2N9rtQ==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
- "@bundled-es-modules/cookie": "^2.0.1",
- "@bundled-es-modules/statuses": "^1.0.1",
- "@bundled-es-modules/tough-cookie": "^0.1.6",
"@inquirer/confirm": "^5.0.0",
- "@mswjs/interceptors": "^0.37.0",
+ "@mswjs/interceptors": "^0.41.2",
"@open-draft/deferred-promise": "^2.2.0",
- "@open-draft/until": "^2.1.0",
- "@types/cookie": "^0.6.0",
- "@types/statuses": "^2.0.4",
- "graphql": "^16.8.1",
+ "@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.10.1",
+ "statuses": "^2.0.2",
"strict-event-emitter": "^0.5.1",
- "type-fest": "^4.26.1",
+ "tough-cookie": "^6.0.0",
+ "type-fest": "^5.2.0",
+ "until-async": "^3.0.2",
"yargs": "^17.7.2"
},
"bin": {
@@ -9053,13 +9136,16 @@
}
},
"node_modules/msw/node_modules/type-fest": {
- "version": "4.32.0",
- "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.32.0.tgz",
- "integrity": "sha512-rfgpoi08xagF3JSdtJlCwMq9DGNDE0IMh3Mkpc1wUypg9vPi786AiqeBBKcqvIkq42azsBM85N490fyZjeUftw==",
+ "version": "5.5.0",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.5.0.tgz",
+ "integrity": "sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==",
"dev": true,
"license": "(MIT OR CC0-1.0)",
+ "dependencies": {
+ "tagged-tag": "^1.0.0"
+ },
"engines": {
- "node": ">=16"
+ "node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
@@ -9100,29 +9186,18 @@
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
"dev": true
},
- "node_modules/node-releases": {
- "version": "2.0.19",
- "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
- "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/npm-run-path": {
- "version": "4.0.1",
- "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz",
- "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==",
+ "node_modules/node-addon-api": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
+ "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
"dev": true,
- "dependencies": {
- "path-key": "^3.0.0"
- },
- "engines": {
- "node": ">=8"
- }
+ "license": "MIT",
+ "optional": true
},
- "node_modules/nwsapi": {
- "version": "2.2.16",
- "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.16.tgz",
- "integrity": "sha512-F1I/bimDpj3ncaNDhfyMWuFqmQDBwDB0Fogc2qpL3BWvkQteFD/8BzWuIRl83rq0DXfm8SGt/HFhLXZyljTXcQ==",
+ "node_modules/node-releases": {
+ "version": "2.0.36",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz",
+ "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==",
"dev": true,
"license": "MIT"
},
@@ -9178,29 +9253,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "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,
- "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==",
+ "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,
- "dependencies": {
- "mimic-fn": "^2.1.0"
- },
- "engines": {
- "node": ">=6"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "license": "MIT"
},
"node_modules/optionator": {
"version": "0.9.4",
@@ -9282,26 +9344,14 @@
"dev": true,
"license": "BlueOak-1.0.0"
},
- "node_modules/parent-module": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
- "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
- "dev": true,
- "dependencies": {
- "callsites": "^3.0.0"
- },
- "engines": {
- "node": ">=6"
- }
- },
"node_modules/parse5": {
- "version": "7.2.1",
- "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.2.1.tgz",
- "integrity": "sha512-BuBYQYlv1ckiPdQi/ohiivi9Sagc9JG+Ozs0r7b/0iK3sKmrb0b9FdWdBbOdx6hBCM/F9Ir82ofnBhtZOjCRPQ==",
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz",
+ "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "entities": "^4.5.0"
+ "entities": "^6.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
@@ -9316,15 +9366,6 @@
"node": ">=8"
}
},
- "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,
- "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",
@@ -9338,31 +9379,8 @@
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
- "dev": true
- },
- "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"
+ "license": "MIT"
},
"node_modules/path-to-regexp": {
"version": "6.3.0",
@@ -9371,15 +9389,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/path-type": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
- "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
- "dev": true,
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
@@ -9387,16 +9396,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/pathval": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
- "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 14.16"
- }
- },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -9584,10 +9583,11 @@
}
},
"node_modules/prettier": {
- "version": "3.2.5",
- "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz",
- "integrity": "sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==",
+ "version": "3.8.1",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
+ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
"dev": true,
+ "license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -9599,21 +9599,18 @@
}
},
"node_modules/prettier-plugin-organize-imports": {
- "version": "3.2.4",
- "resolved": "https://registry.npmjs.org/prettier-plugin-organize-imports/-/prettier-plugin-organize-imports-3.2.4.tgz",
- "integrity": "sha512-6m8WBhIp0dfwu0SkgfOxJqh+HpdyfqSSLfKKRZSFbDuEQXDDndb8fTpRWkUrX/uBenkex3MgnVk0J3b3Y5byog==",
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/prettier-plugin-organize-imports/-/prettier-plugin-organize-imports-4.3.0.tgz",
+ "integrity": "sha512-FxFz0qFhyBsGdIsb697f/EkvHzi5SZOhWAjxcx2dLt+Q532bAlhswcXGYB1yzjZ69kW8UoadFBw7TyNwlq96Iw==",
"dev": true,
+ "license": "MIT",
"peerDependencies": {
- "@volar/vue-language-plugin-pug": "^1.0.4",
- "@volar/vue-typescript": "^1.0.4",
"prettier": ">=2.0",
- "typescript": ">=2.9"
+ "typescript": ">=2.9",
+ "vue-tsc": "^2.1.0 || 3"
},
"peerDependenciesMeta": {
- "@volar/vue-language-plugin-pug": {
- "optional": true
- },
- "@volar/vue-typescript": {
+ "vue-tsc": {
"optional": true
}
}
@@ -9636,7 +9633,6 @@
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
- "peer": true,
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
@@ -9651,7 +9647,6 @@
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
- "peer": true,
"engines": {
"node": ">=10"
},
@@ -9663,22 +9658,22 @@
"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,
- "peer": true
+ "dev": true
},
"node_modules/pretty-quick": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/pretty-quick/-/pretty-quick-4.0.0.tgz",
- "integrity": "sha512-M+2MmeufXb/M7Xw3Afh1gxcYpj+sK0AxEfnfF958ktFeAyi5MsKY5brymVURQLgPLV1QaF5P4pb2oFJ54H3yzQ==",
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/pretty-quick/-/pretty-quick-4.2.2.tgz",
+ "integrity": "sha512-uAh96tBW1SsD34VhhDmWuEmqbpfYc/B3j++5MC/6b3Cb8Ow7NJsvKFhg0eoGu2xXX+o9RkahkTK6sUdd8E7g5w==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "execa": "^5.1.1",
- "find-up": "^5.0.0",
- "ignore": "^5.3.0",
+ "@pkgr/core": "^0.2.7",
+ "ignore": "^7.0.5",
"mri": "^1.2.0",
- "picocolors": "^1.0.0",
- "picomatch": "^3.0.1",
- "tslib": "^2.6.2"
+ "picocolors": "^1.1.1",
+ "picomatch": "^4.0.2",
+ "tinyexec": "^0.3.2",
+ "tslib": "^2.8.1"
},
"bin": {
"pretty-quick": "lib/cli.mjs"
@@ -9686,17 +9681,31 @@
"engines": {
"node": ">=14"
},
+ "funding": {
+ "url": "https://opencollective.com/pretty-quick"
+ },
"peerDependencies": {
"prettier": "^3.0.0"
}
},
+ "node_modules/pretty-quick/node_modules/ignore": {
+ "version": "7.0.5",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
+ "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
"node_modules/pretty-quick/node_modules/picomatch": {
- "version": "3.0.1",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-3.0.1.tgz",
- "integrity": "sha512-I3EurrIQMlRc9IaAZnqRR044Phh2DXY+55o7uJ0V+hYZAcQYSuFWsc9q5PvyDHUSCe1Qxn/iBz+78s86zWnGag==",
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
+ "license": "MIT",
"engines": {
- "node": ">=10"
+ "node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
@@ -9713,21 +9722,12 @@
}
},
"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=="
- },
- "node_modules/psl": {
- "version": "1.15.0",
- "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz",
- "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==",
- "dev": true,
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
+ "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"license": "MIT",
- "dependencies": {
- "punycode": "^2.3.1"
- },
- "funding": {
- "url": "https://github.com/sponsors/lupomontero"
+ "engines": {
+ "node": ">=10"
}
},
"node_modules/punycode": {
@@ -9746,78 +9746,41 @@
"dev": true,
"funding": [
{
- "type": "individual",
- "url": "https://github.com/sponsors/antfu"
- },
- {
- "type": "individual",
- "url": "https://github.com/sponsors/sxzz"
- }
- ],
- "license": "MIT"
- },
- "node_modules/querystringify": {
- "version": "2.2.0",
- "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
- "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/queue-microtask": {
- "version": "1.2.3",
- "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
- "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
- "dev": true,
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/feross"
- },
- {
- "type": "patreon",
- "url": "https://www.patreon.com/feross"
+ "type": "individual",
+ "url": "https://github.com/sponsors/antfu"
},
{
- "type": "consulting",
- "url": "https://feross.org/support"
+ "type": "individual",
+ "url": "https://github.com/sponsors/sxzz"
}
- ]
- },
- "node_modules/randombytes": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
- "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "safe-buffer": "^5.1.0"
- }
+ ],
+ "license": "MIT"
},
"node_modules/react": {
- "version": "19.2.3",
- "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
- "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
+ "version": "19.2.4",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
+ "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
- "version": "19.2.3",
- "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
- "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
+ "version": "19.2.4",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
+ "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"license": "MIT",
"dependencies": {
"scheduler": "^0.27.0"
},
"peerDependencies": {
- "react": "^19.2.3"
+ "react": "^19.2.4"
}
},
"node_modules/react-dropzone": {
- "version": "14.3.8",
- "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.3.8.tgz",
- "integrity": "sha512-sBgODnq+lcA4P296DY4wacOZz3JFpD99fp+hb//iBO2HHnyeZU3FwWyXJ6salNpqQdsZrgMrotuko/BdJMV8Ug==",
+ "version": "15.0.0",
+ "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-15.0.0.tgz",
+ "integrity": "sha512-lGjYV/EoqEjEWPnmiSvH4v5IoIAwQM2W4Z1C0Q/Pw2xD0eVzKPS359BQTUMum+1fa0kH2nrKjuavmTPOGhpLPg==",
"license": "MIT",
"dependencies": {
"attr-accept": "^2.2.4",
@@ -9846,10 +9809,34 @@
"react-dom": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
+ "node_modules/react-redux": {
+ "version": "9.2.0",
+ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
+ "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/use-sync-external-store": "^0.0.6",
+ "use-sync-external-store": "^1.4.0"
+ },
+ "peerDependencies": {
+ "@types/react": "^18.2.25 || ^19",
+ "react": "^18.0 || ^19",
+ "redux": "^5.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "redux": {
+ "optional": true
+ }
+ }
+ },
"node_modules/react-refresh": {
- "version": "0.17.0",
- "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
- "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
+ "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": {
@@ -9904,14 +9891,13 @@
}
},
"node_modules/react-router": {
- "version": "7.5.2",
- "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.5.2.tgz",
- "integrity": "sha512-9Rw8r199klMnlGZ8VAsV/I8WrIF6IyJ90JQUdboupx1cdkgYqwnrYjH+I/nY/7cA1X5zia4mDJqH36npP7sxGQ==",
+ "version": "7.13.2",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.2.tgz",
+ "integrity": "sha512-tX1Aee+ArlKQP+NIUd7SE6Li+CiGKwQtbS+FfRxPX6Pe4vHOo6nr9d++u5cwg+Z8K/x8tP+7qLmujDtfrAoUJA==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
- "set-cookie-parser": "^2.6.0",
- "turbo-stream": "2.4.0"
+ "set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
@@ -9926,22 +9912,6 @@
}
}
},
- "node_modules/react-smooth": {
- "version": "4.0.4",
- "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
- "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "fast-equals": "^5.0.1",
- "prop-types": "^15.8.1",
- "react-transition-group": "^4.4.5"
- },
- "peerDependencies": {
- "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
- "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
- }
- },
"node_modules/react-style-singleton": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
@@ -10006,46 +9976,51 @@
"react-dom": ">=16.6.0"
}
},
- "node_modules/recharts": {
- "version": "2.15.0",
- "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.0.tgz",
- "integrity": "sha512-cIvMxDfpAmqAmVgc4yb7pgm/O1tmmkl/CjrvXuW+62/+7jj/iF9Ykm+hb/UJt42TREHMyd3gb+pkgoa2MxgDIw==",
+ "node_modules/readdirp": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
+ "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"dev": true,
"license": "MIT",
- "dependencies": {
- "clsx": "^2.0.0",
- "eventemitter3": "^4.0.1",
- "lodash": "^4.17.21",
- "react-is": "^18.3.1",
- "react-smooth": "^4.0.0",
- "recharts-scale": "^0.4.4",
- "tiny-invariant": "^1.3.1",
- "victory-vendor": "^36.6.8"
- },
"engines": {
- "node": ">=14"
+ "node": ">= 14.18.0"
},
- "peerDependencies": {
- "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
- "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ "funding": {
+ "type": "individual",
+ "url": "https://paulmillr.com/funding/"
}
},
- "node_modules/recharts-scale": {
- "version": "0.4.5",
- "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz",
- "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==",
+ "node_modules/recharts": {
+ "version": "3.8.1",
+ "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz",
+ "integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==",
"dev": true,
+ "license": "MIT",
+ "workspaces": [
+ "www"
+ ],
"dependencies": {
- "decimal.js-light": "^2.4.1"
+ "@reduxjs/toolkit": "^1.9.0 || 2.x.x",
+ "clsx": "^2.1.1",
+ "decimal.js-light": "^2.5.1",
+ "es-toolkit": "^1.39.3",
+ "eventemitter3": "^5.0.1",
+ "immer": "^10.1.1",
+ "react-redux": "8.x.x || 9.x.x",
+ "reselect": "5.1.1",
+ "tiny-invariant": "^1.3.3",
+ "use-sync-external-store": "^1.2.2",
+ "victory-vendor": "^37.0.2"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
- "node_modules/recharts/node_modules/react-is": {
- "version": "18.3.1",
- "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
- "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/redent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
@@ -10059,6 +10034,23 @@
"node": ">=8"
}
},
+ "node_modules/redux": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
+ "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/redux-thunk": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
+ "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "redux": "^5.0.0"
+ }
+ },
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -10090,9 +10082,9 @@
"license": "MIT"
},
"node_modules/regenerate-unicode-properties": {
- "version": "10.2.0",
- "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.0.tgz",
- "integrity": "sha512-DqHn3DwbmmPVzeKj9woBadqmXxLvQoQIwu7nopMc72ztvxVmVk2SBhSnx67zuye5TP+lJsb/TBQsjLKhnDf3MA==",
+ "version": "10.2.2",
+ "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.2.tgz",
+ "integrity": "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -10107,16 +10099,6 @@
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw=="
},
- "node_modules/regenerator-transform": {
- "version": "0.15.2",
- "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.15.2.tgz",
- "integrity": "sha512-hfMp2BoF0qOk3uc5V20ALGDS2ddjQaLrdl7xrGXvAIow7qeWRM2VA2HuCHkUKk9slq3VwEwLNK3DFBqDfPGYtg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/runtime": "^7.8.4"
- }
- },
"node_modules/regexp.prototype.flags": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
@@ -10139,18 +10121,18 @@
}
},
"node_modules/regexpu-core": {
- "version": "6.2.0",
- "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.2.0.tgz",
- "integrity": "sha512-H66BPQMrv+V16t8xtmq+UC0CBpiTBA60V8ibS1QVReIp8T1z8hwFxqcGzm9K6lgsN7sB5edVH8a+ze6Fqm4weA==",
+ "version": "6.4.0",
+ "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.4.0.tgz",
+ "integrity": "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==",
"dev": true,
"license": "MIT",
"dependencies": {
"regenerate": "^1.4.2",
- "regenerate-unicode-properties": "^10.2.0",
+ "regenerate-unicode-properties": "^10.2.2",
"regjsgen": "^0.8.0",
- "regjsparser": "^0.12.0",
+ "regjsparser": "^0.13.0",
"unicode-match-property-ecmascript": "^2.0.0",
- "unicode-match-property-value-ecmascript": "^2.1.0"
+ "unicode-match-property-value-ecmascript": "^2.2.1"
},
"engines": {
"node": ">=4"
@@ -10164,28 +10146,18 @@
"license": "MIT"
},
"node_modules/regjsparser": {
- "version": "0.12.0",
- "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.12.0.tgz",
- "integrity": "sha512-cnE+y8bz4NhMjISKbgeVJtqNbtf5QpjZP+Bslo+UqkIt9QPnX9q095eiRRASJG1/tz6dlNr6Z5NsBiWYokp6EQ==",
+ "version": "0.13.0",
+ "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.0.tgz",
+ "integrity": "sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
- "jsesc": "~3.0.2"
+ "jsesc": "~3.1.0"
},
"bin": {
"regjsparser": "bin/parser"
}
},
- "node_modules/regjsparser/node_modules/jsesc": {
- "version": "3.0.2",
- "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz",
- "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==",
- "dev": true,
- "license": "MIT",
- "bin": {
- "jsesc": "bin/jsesc"
- }
- },
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@@ -10206,21 +10178,21 @@
"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==",
+ "node_modules/reselect": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
+ "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"dev": true,
"license": "MIT"
},
"node_modules/resolve": {
- "version": "1.22.10",
- "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
- "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==",
+ "version": "1.22.11",
+ "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
+ "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "is-core-module": "^2.16.0",
+ "is-core-module": "^2.16.1",
"path-parse": "^1.0.7",
"supports-preserve-symlinks-flag": "^1.0.0"
},
@@ -10234,44 +10206,17 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/resolve-from": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
- "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
- "dev": true,
- "engines": {
- "node": ">=4"
- }
- },
- "node_modules/reusify": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
- "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==",
- "dev": true,
- "engines": {
- "iojs": ">=1.0.0",
- "node": ">=0.10.0"
- }
- },
- "node_modules/rimraf": {
- "version": "3.0.2",
- "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
- "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
+ "node_modules/rettime": {
+ "version": "0.10.1",
+ "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.10.1.tgz",
+ "integrity": "sha512-uyDrIlUEH37cinabq0AX4QbgV4HbFZ/gqoiunWQ1UqBtRvTTytwhNYjE++pO/MjPTZL5KQCf2bEoJ/BJNVQ5Kw==",
"dev": true,
- "dependencies": {
- "glob": "^7.1.3"
- },
- "bin": {
- "rimraf": "bin.js"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- }
+ "license": "MIT"
},
"node_modules/rollup": {
- "version": "4.48.1",
- "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.48.1.tgz",
- "integrity": "sha512-jVG20NvbhTYDkGAty2/Yh7HK6/q3DGSRH4o8ALKGArmMuaauM9kLfoMZ+WliPwA5+JHr2lTn3g557FxBV87ifg==",
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
+ "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -10285,59 +10230,34 @@
"npm": ">=8.0.0"
},
"optionalDependencies": {
- "@rollup/rollup-android-arm-eabi": "4.48.1",
- "@rollup/rollup-android-arm64": "4.48.1",
- "@rollup/rollup-darwin-arm64": "4.48.1",
- "@rollup/rollup-darwin-x64": "4.48.1",
- "@rollup/rollup-freebsd-arm64": "4.48.1",
- "@rollup/rollup-freebsd-x64": "4.48.1",
- "@rollup/rollup-linux-arm-gnueabihf": "4.48.1",
- "@rollup/rollup-linux-arm-musleabihf": "4.48.1",
- "@rollup/rollup-linux-arm64-gnu": "4.48.1",
- "@rollup/rollup-linux-arm64-musl": "4.48.1",
- "@rollup/rollup-linux-loongarch64-gnu": "4.48.1",
- "@rollup/rollup-linux-ppc64-gnu": "4.48.1",
- "@rollup/rollup-linux-riscv64-gnu": "4.48.1",
- "@rollup/rollup-linux-riscv64-musl": "4.48.1",
- "@rollup/rollup-linux-s390x-gnu": "4.48.1",
- "@rollup/rollup-linux-x64-gnu": "4.48.1",
- "@rollup/rollup-linux-x64-musl": "4.48.1",
- "@rollup/rollup-win32-arm64-msvc": "4.48.1",
- "@rollup/rollup-win32-ia32-msvc": "4.48.1",
- "@rollup/rollup-win32-x64-msvc": "4.48.1",
+ "@rollup/rollup-android-arm-eabi": "4.59.0",
+ "@rollup/rollup-android-arm64": "4.59.0",
+ "@rollup/rollup-darwin-arm64": "4.59.0",
+ "@rollup/rollup-darwin-x64": "4.59.0",
+ "@rollup/rollup-freebsd-arm64": "4.59.0",
+ "@rollup/rollup-freebsd-x64": "4.59.0",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
+ "@rollup/rollup-linux-arm-musleabihf": "4.59.0",
+ "@rollup/rollup-linux-arm64-gnu": "4.59.0",
+ "@rollup/rollup-linux-arm64-musl": "4.59.0",
+ "@rollup/rollup-linux-loong64-gnu": "4.59.0",
+ "@rollup/rollup-linux-loong64-musl": "4.59.0",
+ "@rollup/rollup-linux-ppc64-gnu": "4.59.0",
+ "@rollup/rollup-linux-ppc64-musl": "4.59.0",
+ "@rollup/rollup-linux-riscv64-gnu": "4.59.0",
+ "@rollup/rollup-linux-riscv64-musl": "4.59.0",
+ "@rollup/rollup-linux-s390x-gnu": "4.59.0",
+ "@rollup/rollup-linux-x64-gnu": "4.59.0",
+ "@rollup/rollup-linux-x64-musl": "4.59.0",
+ "@rollup/rollup-openbsd-x64": "4.59.0",
+ "@rollup/rollup-openharmony-arm64": "4.59.0",
+ "@rollup/rollup-win32-arm64-msvc": "4.59.0",
+ "@rollup/rollup-win32-ia32-msvc": "4.59.0",
+ "@rollup/rollup-win32-x64-gnu": "4.59.0",
+ "@rollup/rollup-win32-x64-msvc": "4.59.0",
"fsevents": "~2.3.2"
}
},
- "node_modules/rrweb-cssom": {
- "version": "0.8.0",
- "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz",
- "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/run-parallel": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
- "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
- "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"
- }
- ],
- "dependencies": {
- "queue-microtask": "^1.2.2"
- }
- },
"node_modules/rxjs": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
@@ -10368,27 +10288,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "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/safe-push-apply": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
@@ -10424,23 +10323,38 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/safer-buffer": {
- "version": "2.1.2",
- "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
- "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
- "dev": true
+ "node_modules/sass": {
+ "version": "1.98.0",
+ "resolved": "https://registry.npmjs.org/sass/-/sass-1.98.0.tgz",
+ "integrity": "sha512-+4N/u9dZ4PrgzGgPlKnaaRQx64RO0JBKs9sDhQ2pLgN6JQZ25uPQZKQYaBJU48Kd5BxgXoJ4e09Dq7nMcOUW3A==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "chokidar": "^4.0.0",
+ "immutable": "^5.1.5",
+ "source-map-js": ">=0.6.2 <2.0.0"
+ },
+ "bin": {
+ "sass": "sass.js"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "optionalDependencies": {
+ "@parcel/watcher": "^2.4.1"
+ }
},
"node_modules/sass-embedded": {
- "version": "1.86.1",
- "resolved": "https://registry.npmjs.org/sass-embedded/-/sass-embedded-1.86.1.tgz",
- "integrity": "sha512-LMJvytHh7lIUtmjGCqpM4cRdIDvPllLJKznNIK4L7EZJ77BLeUFoOSRXEOHq4G4gqy5CVhHUKlHslzCANkDOhQ==",
+ "version": "1.98.0",
+ "resolved": "https://registry.npmjs.org/sass-embedded/-/sass-embedded-1.98.0.tgz",
+ "integrity": "sha512-Do7u6iRb6K+lrllcTkB1BXcHwOxcKe3rEfOF/GcCLE2w3WpddakRAosJOHFUR37DpsvimQXEt5abs3NzUjEIqg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@bufbuild/protobuf": "^2.0.0",
- "buffer-builder": "^0.2.0",
+ "@bufbuild/protobuf": "^2.5.0",
"colorjs.io": "^0.5.0",
- "immutable": "^5.0.2",
+ "immutable": "^5.1.5",
"rxjs": "^7.4.0",
"supports-color": "^8.1.1",
"sync-child-process": "^1.0.2",
@@ -10453,51 +10367,49 @@
"node": ">=16.0.0"
},
"optionalDependencies": {
- "sass-embedded-android-arm": "1.86.1",
- "sass-embedded-android-arm64": "1.86.1",
- "sass-embedded-android-ia32": "1.86.1",
- "sass-embedded-android-riscv64": "1.86.1",
- "sass-embedded-android-x64": "1.86.1",
- "sass-embedded-darwin-arm64": "1.86.1",
- "sass-embedded-darwin-x64": "1.86.1",
- "sass-embedded-linux-arm": "1.86.1",
- "sass-embedded-linux-arm64": "1.86.1",
- "sass-embedded-linux-ia32": "1.86.1",
- "sass-embedded-linux-musl-arm": "1.86.1",
- "sass-embedded-linux-musl-arm64": "1.86.1",
- "sass-embedded-linux-musl-ia32": "1.86.1",
- "sass-embedded-linux-musl-riscv64": "1.86.1",
- "sass-embedded-linux-musl-x64": "1.86.1",
- "sass-embedded-linux-riscv64": "1.86.1",
- "sass-embedded-linux-x64": "1.86.1",
- "sass-embedded-win32-arm64": "1.86.1",
- "sass-embedded-win32-ia32": "1.86.1",
- "sass-embedded-win32-x64": "1.86.1"
- }
- },
- "node_modules/sass-embedded-android-arm": {
- "version": "1.86.1",
- "resolved": "https://registry.npmjs.org/sass-embedded-android-arm/-/sass-embedded-android-arm-1.86.1.tgz",
- "integrity": "sha512-bcmKB67uCb9znune+QsE6cWIiKAHE9P+24/9vDPHwwN3BmmH1B/4mznNKKakdYMuxpgbeLrPcEScHEpQbdrIpA==",
+ "sass-embedded-all-unknown": "1.98.0",
+ "sass-embedded-android-arm": "1.98.0",
+ "sass-embedded-android-arm64": "1.98.0",
+ "sass-embedded-android-riscv64": "1.98.0",
+ "sass-embedded-android-x64": "1.98.0",
+ "sass-embedded-darwin-arm64": "1.98.0",
+ "sass-embedded-darwin-x64": "1.98.0",
+ "sass-embedded-linux-arm": "1.98.0",
+ "sass-embedded-linux-arm64": "1.98.0",
+ "sass-embedded-linux-musl-arm": "1.98.0",
+ "sass-embedded-linux-musl-arm64": "1.98.0",
+ "sass-embedded-linux-musl-riscv64": "1.98.0",
+ "sass-embedded-linux-musl-x64": "1.98.0",
+ "sass-embedded-linux-riscv64": "1.98.0",
+ "sass-embedded-linux-x64": "1.98.0",
+ "sass-embedded-unknown-all": "1.98.0",
+ "sass-embedded-win32-arm64": "1.98.0",
+ "sass-embedded-win32-x64": "1.98.0"
+ }
+ },
+ "node_modules/sass-embedded-all-unknown": {
+ "version": "1.98.0",
+ "resolved": "https://registry.npmjs.org/sass-embedded-all-unknown/-/sass-embedded-all-unknown-1.98.0.tgz",
+ "integrity": "sha512-6n4RyK7/1mhdfYvpP3CClS3fGoYqDvRmLClCESS6I7+SAzqjxvGG6u5Fo+cb1nrPNbbilgbM4QKdgcgWHO9NCA==",
"cpu": [
- "arm"
+ "!arm",
+ "!arm64",
+ "!riscv64",
+ "!x64"
],
"dev": true,
"license": "MIT",
"optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">=14.0.0"
+ "dependencies": {
+ "sass": "1.98.0"
}
},
- "node_modules/sass-embedded-android-arm64": {
- "version": "1.86.1",
- "resolved": "https://registry.npmjs.org/sass-embedded-android-arm64/-/sass-embedded-android-arm64-1.86.1.tgz",
- "integrity": "sha512-SMY79YhNfq/gdz8MHqwEsnf/IjSnQFAmSEGDDv0vjL0yy9VZC/zhsxpsho8vbFEvTSEGFFlkGgPdzDuoozRrOg==",
+ "node_modules/sass-embedded-android-arm": {
+ "version": "1.98.0",
+ "resolved": "https://registry.npmjs.org/sass-embedded-android-arm/-/sass-embedded-android-arm-1.98.0.tgz",
+ "integrity": "sha512-LjGiMhHgu7VL1n7EJxTCre1x14bUsWd9d3dnkS2rku003IWOI/fxc7OXgaKagoVzok1kv09rzO3vFXJR5ZeONQ==",
"cpu": [
- "arm64"
+ "arm"
],
"dev": true,
"license": "MIT",
@@ -10509,12 +10421,12 @@
"node": ">=14.0.0"
}
},
- "node_modules/sass-embedded-android-ia32": {
- "version": "1.86.1",
- "resolved": "https://registry.npmjs.org/sass-embedded-android-ia32/-/sass-embedded-android-ia32-1.86.1.tgz",
- "integrity": "sha512-AX6I5qS8GbgcbBJ1o3uKVI5/7tq6evg/BO/wa0XaNqnzP4i/PojBaGh7EcZrg/spl//SfpS55eA18a0/AOi71w==",
+ "node_modules/sass-embedded-android-arm64": {
+ "version": "1.98.0",
+ "resolved": "https://registry.npmjs.org/sass-embedded-android-arm64/-/sass-embedded-android-arm64-1.98.0.tgz",
+ "integrity": "sha512-M9Ra98A6vYJHpwhoC/5EuH1eOshQ9ZyNwC8XifUDSbRl/cGeQceT1NReR9wFj3L7s1pIbmes1vMmaY2np0uAKQ==",
"cpu": [
- "ia32"
+ "arm64"
],
"dev": true,
"license": "MIT",
@@ -10527,9 +10439,9 @@
}
},
"node_modules/sass-embedded-android-riscv64": {
- "version": "1.86.1",
- "resolved": "https://registry.npmjs.org/sass-embedded-android-riscv64/-/sass-embedded-android-riscv64-1.86.1.tgz",
- "integrity": "sha512-Af6ZzRTRfIfx6KICJZ19je6OjOXhxo+v6z/lf/SXm5/1EaHGpGC5xIw4ivtj4nNINNoqkykfIDCjpzm1qWEPPQ==",
+ "version": "1.98.0",
+ "resolved": "https://registry.npmjs.org/sass-embedded-android-riscv64/-/sass-embedded-android-riscv64-1.98.0.tgz",
+ "integrity": "sha512-WPe+0NbaJIZE1fq/RfCZANMeIgmy83x4f+SvFOG7LhUthHpZWcOcrPTsCKKmN3xMT3iw+4DXvqTYOCYGRL3hcQ==",
"cpu": [
"riscv64"
],
@@ -10544,9 +10456,9 @@
}
},
"node_modules/sass-embedded-android-x64": {
- "version": "1.86.1",
- "resolved": "https://registry.npmjs.org/sass-embedded-android-x64/-/sass-embedded-android-x64-1.86.1.tgz",
- "integrity": "sha512-GW47z1AH8gXB7IG6EUbC5aDBDtiITeP5nUfEenE6vaaN0H17mBjIwSnEcKPPA1IdxzDpj+4bE/SGfiF0W/At4g==",
+ "version": "1.98.0",
+ "resolved": "https://registry.npmjs.org/sass-embedded-android-x64/-/sass-embedded-android-x64-1.98.0.tgz",
+ "integrity": "sha512-zrD25dT7OHPEgLWuPEByybnIfx4rnCtfge4clBgjZdZ3lF6E7qNLRBtSBmoFflh6Vg0RlEjJo5VlpnTMBM5MQQ==",
"cpu": [
"x64"
],
@@ -10561,9 +10473,9 @@
}
},
"node_modules/sass-embedded-darwin-arm64": {
- "version": "1.86.1",
- "resolved": "https://registry.npmjs.org/sass-embedded-darwin-arm64/-/sass-embedded-darwin-arm64-1.86.1.tgz",
- "integrity": "sha512-grBnDW5Rg+mEmZM7I9hJySS4MMXDwLMd+RyegQnr+SIJ3WA807Cw830+raALxgDY+UKKKhVEoq3FgbTo40Awgw==",
+ "version": "1.98.0",
+ "resolved": "https://registry.npmjs.org/sass-embedded-darwin-arm64/-/sass-embedded-darwin-arm64-1.98.0.tgz",
+ "integrity": "sha512-cgr1z9rBnCdMf8K+JabIaYd9Rag2OJi5mjq08XJfbJGMZV/TA6hFJCLGkr5/+ZOn4/geTM5/3aSfQ8z5EIJAOg==",
"cpu": [
"arm64"
],
@@ -10578,9 +10490,9 @@
}
},
"node_modules/sass-embedded-darwin-x64": {
- "version": "1.86.1",
- "resolved": "https://registry.npmjs.org/sass-embedded-darwin-x64/-/sass-embedded-darwin-x64-1.86.1.tgz",
- "integrity": "sha512-XxSCMcmeADNouiJAr8G1oRnEhkivHKVLV5DRpfFnUK5FqtFCuSk3K18I+xIfpQDeZnjRL3t2VjsmEJuFiBYV8w==",
+ "version": "1.98.0",
+ "resolved": "https://registry.npmjs.org/sass-embedded-darwin-x64/-/sass-embedded-darwin-x64-1.98.0.tgz",
+ "integrity": "sha512-OLBOCs/NPeiMqTdOrMFbVHBQFj19GS3bSVSxIhcCq16ZyhouUkYJEZjxQgzv9SWA2q6Ki8GCqp4k6jMeUY9dcA==",
"cpu": [
"x64"
],
@@ -10595,9 +10507,9 @@
}
},
"node_modules/sass-embedded-linux-arm": {
- "version": "1.86.1",
- "resolved": "https://registry.npmjs.org/sass-embedded-linux-arm/-/sass-embedded-linux-arm-1.86.1.tgz",
- "integrity": "sha512-Z57ZUcWPuoOHpnl3TiUf/x9wWF2dFtkjdv7hZQpFXYwK5eudHFeBErK6KNCos6jkif1KyeFELXT/HWOznitU/w==",
+ "version": "1.98.0",
+ "resolved": "https://registry.npmjs.org/sass-embedded-linux-arm/-/sass-embedded-linux-arm-1.98.0.tgz",
+ "integrity": "sha512-03baQZCxVyEp8v1NWBRlzGYrmVT/LK7ZrHlF1piscGiGxwfdxoLXVuxsylx3qn/dD/4i/rh7Bzk7reK1br9jvQ==",
"cpu": [
"arm"
],
@@ -10612,9 +10524,9 @@
}
},
"node_modules/sass-embedded-linux-arm64": {
- "version": "1.86.1",
- "resolved": "https://registry.npmjs.org/sass-embedded-linux-arm64/-/sass-embedded-linux-arm64-1.86.1.tgz",
- "integrity": "sha512-zchms0BtaOrkvfvjRnl1PDWK931DxAeYEY2yKQceO/0OFtcBz1r480Kh/RjIffTNreJqIr9Mx4wFdP+icKwLpg==",
+ "version": "1.98.0",
+ "resolved": "https://registry.npmjs.org/sass-embedded-linux-arm64/-/sass-embedded-linux-arm64-1.98.0.tgz",
+ "integrity": "sha512-axOE3t2MTBwCtkUCbrdM++Gj0gC0fdHJPrgzQ+q1WUmY9NoNMGqflBtk5mBZaWUeha2qYO3FawxCB8lctFwCtw==",
"cpu": [
"arm64"
],
@@ -10628,27 +10540,10 @@
"node": ">=14.0.0"
}
},
- "node_modules/sass-embedded-linux-ia32": {
- "version": "1.86.1",
- "resolved": "https://registry.npmjs.org/sass-embedded-linux-ia32/-/sass-embedded-linux-ia32-1.86.1.tgz",
- "integrity": "sha512-WHntVnCgpiJPCmTeQrn5rtl1zJdd693TwpNGAFPzKD4FILPcVBKtWutl7COL6bKe/mKTf9OW0t6GBJ6mav2hAA==",
- "cpu": [
- "ia32"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=14.0.0"
- }
- },
"node_modules/sass-embedded-linux-musl-arm": {
- "version": "1.86.1",
- "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm/-/sass-embedded-linux-musl-arm-1.86.1.tgz",
- "integrity": "sha512-DlPpyp3bIL8YMtxR22hkWBtuZY6ch3KAmQvqIONippPv96WTHi1iq5jclbE1YXpDtI8Wcus0x6apoDSKq8o95g==",
+ "version": "1.98.0",
+ "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm/-/sass-embedded-linux-musl-arm-1.98.0.tgz",
+ "integrity": "sha512-OBkjTDPYR4hSaueOGIM6FDpl9nt/VZwbSRpbNu9/eEJcxE8G/vynRugW8KRZmCFjPy8j/jkGBvvS+k9iOqKV3g==",
"cpu": [
"arm"
],
@@ -10663,9 +10558,9 @@
}
},
"node_modules/sass-embedded-linux-musl-arm64": {
- "version": "1.86.1",
- "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm64/-/sass-embedded-linux-musl-arm64-1.86.1.tgz",
- "integrity": "sha512-CwuHMRWSJFByHpgqcVtCSt29dMWhr0lpUTjaBCh9xOl0Oyz89dIqOxA0aMq+XU+thaDtOziJtMIfW6l35ZeykQ==",
+ "version": "1.98.0",
+ "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm64/-/sass-embedded-linux-musl-arm64-1.98.0.tgz",
+ "integrity": "sha512-LeqNxQA8y4opjhe68CcFvMzCSrBuJqYVFbwElEj9bagHXQHTp9xVPJRn6VcrC+0VLEDq13HVXMv7RslIuU0zmA==",
"cpu": [
"arm64"
],
@@ -10679,27 +10574,10 @@
"node": ">=14.0.0"
}
},
- "node_modules/sass-embedded-linux-musl-ia32": {
- "version": "1.86.1",
- "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-ia32/-/sass-embedded-linux-musl-ia32-1.86.1.tgz",
- "integrity": "sha512-yjvVpAW1YS0VQNnIUtZTf0IrRDMa0wRjFWUtsLthVIxuXyjLy44+YULlfduxqcZe3rvI4+EqT7GorvviWo9NfQ==",
- "cpu": [
- "ia32"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=14.0.0"
- }
- },
"node_modules/sass-embedded-linux-musl-riscv64": {
- "version": "1.86.1",
- "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-riscv64/-/sass-embedded-linux-musl-riscv64-1.86.1.tgz",
- "integrity": "sha512-0zCUOMwX/hwPV1zimxM46dq/MdATSqbw6G646DwQ3/2V2Db1t9lfXBZqSavx8p/cqRp1JYTUPbJQV1gT4J7NYw==",
+ "version": "1.98.0",
+ "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-riscv64/-/sass-embedded-linux-musl-riscv64-1.98.0.tgz",
+ "integrity": "sha512-7w6hSuOHKt8FZsmjRb3iGSxEzM87fO9+M8nt5JIQYMhHTj5C+JY/vcske0v715HCVj5e1xyTnbGXf8FcASeAIw==",
"cpu": [
"riscv64"
],
@@ -10714,9 +10592,9 @@
}
},
"node_modules/sass-embedded-linux-musl-x64": {
- "version": "1.86.1",
- "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-x64/-/sass-embedded-linux-musl-x64-1.86.1.tgz",
- "integrity": "sha512-8KJ6kEj1N16V9E0g5PDSd4aVe1LwcVKROJcVqnzTKPMa/4j2VuNWep7D81OYchdQMm9Egn1RqV0jCwm0b2aSHQ==",
+ "version": "1.98.0",
+ "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-x64/-/sass-embedded-linux-musl-x64-1.98.0.tgz",
+ "integrity": "sha512-QikNyDEJOVqPmxyCFkci8ZdCwEssdItfjQFJB+D+Uy5HFqcS5Lv3d3GxWNX/h1dSb23RPyQdQc267ok5SbEyJw==",
"cpu": [
"x64"
],
@@ -10731,9 +10609,9 @@
}
},
"node_modules/sass-embedded-linux-riscv64": {
- "version": "1.86.1",
- "resolved": "https://registry.npmjs.org/sass-embedded-linux-riscv64/-/sass-embedded-linux-riscv64-1.86.1.tgz",
- "integrity": "sha512-rNJ1EfIkQpvBfMS1fBdyb+Gsji4yK0AwsV1T7NEcy21yDxDt7mdCgkAJiaN9qf7UEXuCuueQoed7WZoDaSpjww==",
+ "version": "1.98.0",
+ "resolved": "https://registry.npmjs.org/sass-embedded-linux-riscv64/-/sass-embedded-linux-riscv64-1.98.0.tgz",
+ "integrity": "sha512-E7fNytc/v4xFBQKzgzBddV/jretA4ULAPO6XmtBiQu4zZBdBozuSxsQLe2+XXeb0X4S2GIl72V7IPABdqke/vA==",
"cpu": [
"riscv64"
],
@@ -10748,9 +10626,9 @@
}
},
"node_modules/sass-embedded-linux-x64": {
- "version": "1.86.1",
- "resolved": "https://registry.npmjs.org/sass-embedded-linux-x64/-/sass-embedded-linux-x64-1.86.1.tgz",
- "integrity": "sha512-DGCdUoYRRUKzRZz/q7plbB5Nean2+Uk4CqKF4RWAU0v1tHnDKKWmYfETryhWdB2WJM8QSn7O8qRebe6FCobB5g==",
+ "version": "1.98.0",
+ "resolved": "https://registry.npmjs.org/sass-embedded-linux-x64/-/sass-embedded-linux-x64-1.98.0.tgz",
+ "integrity": "sha512-VsvP0t/uw00mMNPv3vwyYKUrFbqzxQHnRMO+bHdAMjvLw4NFf6mscpym9Bzf+NXwi1ZNKnB6DtXjmcpcvqFqYg==",
"cpu": [
"x64"
],
@@ -10764,29 +10642,29 @@
"node": ">=14.0.0"
}
},
- "node_modules/sass-embedded-win32-arm64": {
- "version": "1.86.1",
- "resolved": "https://registry.npmjs.org/sass-embedded-win32-arm64/-/sass-embedded-win32-arm64-1.86.1.tgz",
- "integrity": "sha512-qRLZR3yLuk/3y64YhcltkwGclhPoK6EdiLP1e5SVw5+kughcs+mNUZ3rdvSAmCSA4vDv+XOiOjRpjxmpeon95Q==",
- "cpu": [
- "arm64"
- ],
+ "node_modules/sass-embedded-unknown-all": {
+ "version": "1.98.0",
+ "resolved": "https://registry.npmjs.org/sass-embedded-unknown-all/-/sass-embedded-unknown-all-1.98.0.tgz",
+ "integrity": "sha512-C4MMzcAo3oEDQnW7L8SBgB9F2Fq5qHPnaYTZRMOH3Mp/7kM4OooBInXpCiiFjLnjY95hzP4KyctVx0uYR6MYlQ==",
"dev": true,
"license": "MIT",
"optional": true,
"os": [
- "win32"
+ "!android",
+ "!darwin",
+ "!linux",
+ "!win32"
],
- "engines": {
- "node": ">=14.0.0"
+ "dependencies": {
+ "sass": "1.98.0"
}
},
- "node_modules/sass-embedded-win32-ia32": {
- "version": "1.86.1",
- "resolved": "https://registry.npmjs.org/sass-embedded-win32-ia32/-/sass-embedded-win32-ia32-1.86.1.tgz",
- "integrity": "sha512-o860a7/YGHZnGeY3l/e6yt3+ZMeDdDHmthTaKnw2wpJNEq0nmytYLTJQmjWPxEMz7O8AQ0LtcbDDrhivSog+KQ==",
+ "node_modules/sass-embedded-win32-arm64": {
+ "version": "1.98.0",
+ "resolved": "https://registry.npmjs.org/sass-embedded-win32-arm64/-/sass-embedded-win32-arm64-1.98.0.tgz",
+ "integrity": "sha512-nP/10xbAiPbhQkMr3zQfXE4TuOxPzWRQe1Hgbi90jv2R4TbzbqQTuZVOaJf7KOAN4L2Bo6XCTRjK5XkVnwZuwQ==",
"cpu": [
- "ia32"
+ "arm64"
],
"dev": true,
"license": "MIT",
@@ -10799,9 +10677,9 @@
}
},
"node_modules/sass-embedded-win32-x64": {
- "version": "1.86.1",
- "resolved": "https://registry.npmjs.org/sass-embedded-win32-x64/-/sass-embedded-win32-x64-1.86.1.tgz",
- "integrity": "sha512-7Z3wsVKfseJodmv689dDEV/JrXJH5TAclWNvHrEYW5BtoViOTU2pIDxRgLYzdKU9teIw5g6R0nJZb9M105oIKA==",
+ "version": "1.98.0",
+ "resolved": "https://registry.npmjs.org/sass-embedded-win32-x64/-/sass-embedded-win32-x64-1.98.0.tgz",
+ "integrity": "sha512-/lbrVsfbcbdZQ5SJCWcV0NVPd6YRs+FtAnfedp4WbCkO/ZO7Zt/58MvI4X2BVpRY/Nt5ZBo1/7v2gYcQ+J4svQ==",
"cpu": [
"x64"
],
@@ -10815,13 +10693,6 @@
"node": ">=14.0.0"
}
},
- "node_modules/sass-embedded/node_modules/immutable": {
- "version": "5.1.1",
- "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.1.tgz",
- "integrity": "sha512-3jatXi9ObIsPGr3N5hGw/vWWcTkq6hUYhpQz4k0wLC+owqWi/LiugIw9x0EdNZ2yGedKN/HzePiBvaJRXa0Ujg==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/sass-embedded/node_modules/supports-color": {
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
@@ -10866,13 +10737,13 @@
}
},
"node_modules/serialize-javascript": {
- "version": "6.0.2",
- "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz",
- "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==",
+ "version": "7.0.4",
+ "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.4.tgz",
+ "integrity": "sha512-DuGdB+Po43Q5Jxwpzt1lhyFSYKryqoNjQSA9M92tyw0lyHIOur+XCalOUe0KTJpyqzT8+fQ5A0Jf7vCx/NKmIg==",
"dev": true,
"license": "BSD-3-Clause",
- "dependencies": {
- "randombytes": "^2.1.0"
+ "engines": {
+ "node": ">=20.0.0"
}
},
"node_modules/set-cookie-parser": {
@@ -11099,12 +10970,6 @@
"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
- },
"node_modules/simple-swizzle": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz",
@@ -11123,9 +10988,9 @@
"license": "MIT"
},
"node_modules/sirv": {
- "version": "3.0.1",
- "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.1.tgz",
- "integrity": "sha512-FoqMu0NCGBLCcAkS1qA+XJIQTR6/JHfQXl+uGteNCQ76T91DMUjPa9xfmeqMY3z80nLSg9yQmNjK0Px6RWsH/A==",
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
+ "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -11142,31 +11007,59 @@
"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
"integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==",
"dev": true,
+ "license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/smob": {
- "version": "1.5.0",
- "resolved": "https://registry.npmjs.org/smob/-/smob-1.5.0.tgz",
- "integrity": "sha512-g6T+p7QO8npa+/hNx9ohv1E5pVCmWrVCUzUXJyLdMmftX6ER0oiWY/w9knEonLpnOp6b6FenKnMfR8gqwWdwig==",
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/smob/-/smob-1.6.1.tgz",
+ "integrity": "sha512-KAkBqZl3c2GvNgNhcoyJae1aKldDW0LO279wF9bk1PnluRTETKBq0WyzRXxEhoQLk56yHaOY4JCBEKDuJIET5g==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.0.0"
+ }
},
"node_modules/socket.io-client": {
- "version": "4.7.5",
- "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.7.5.tgz",
- "integrity": "sha512-sJ/tqHOCe7Z50JCBCXrsY3I2k03iOiUe+tj1OmKeD2lXPiGH/RUCdTZFoqVyN7l1MnpIzPrGtLcijffmeouNlQ==",
+ "version": "4.8.3",
+ "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz",
+ "integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==",
+ "license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
- "debug": "~4.3.2",
- "engine.io-client": "~6.5.2",
+ "debug": "~4.4.1",
+ "engine.io-client": "~6.6.1",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.0.0"
}
},
+ "node_modules/socket.io-client/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/socket.io-client/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/socket.io-parser": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz",
@@ -11183,6 +11076,7 @@
"version": "0.8.0-beta.0",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz",
"integrity": "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==",
+ "deprecated": "The work that was done in this beta branch won't be included in future versions",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
@@ -11265,6 +11159,7 @@
"resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz",
"integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"escape-string-regexp": "^2.0.0"
},
@@ -11277,6 +11172,7 @@
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz",
"integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==",
"dev": true,
+ "license": "MIT",
"engines": {
"node": ">=8"
}
@@ -11288,9 +11184,9 @@
"dev": true
},
"node_modules/statuses": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
- "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -11298,12 +11194,26 @@
}
},
"node_modules/std-env": {
- "version": "3.9.0",
- "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz",
- "integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==",
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz",
+ "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==",
"dev": true,
"license": "MIT"
},
+ "node_modules/stop-iteration-iterator": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
+ "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "internal-slot": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/strict-event-emitter": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz",
@@ -11326,22 +11236,6 @@
"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/string.prototype.matchall": {
"version": "4.0.12",
"resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz",
@@ -11456,20 +11350,6 @@
"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-comments": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-comments/-/strip-comments-2.0.1.tgz",
@@ -11480,15 +11360,6 @@
"node": ">=10"
}
},
- "node_modules/strip-final-newline": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz",
- "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==",
- "dev": true,
- "engines": {
- "node": ">=6"
- }
- },
"node_modules/strip-indent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
@@ -11501,38 +11372,6 @@
"node": ">=8"
}
},
- "node_modules/strip-json-comments": {
- "version": "3.1.1",
- "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
- "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
- "dev": true,
- "engines": {
- "node": ">=8"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/strip-literal": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.0.0.tgz",
- "integrity": "sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "js-tokens": "^9.0.1"
- },
- "funding": {
- "url": "https://github.com/sponsors/antfu"
- }
- },
- "node_modules/strip-literal/node_modules/js-tokens": {
- "version": "9.0.1",
- "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
- "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/sugarss": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/sugarss/-/sugarss-5.0.1.tgz",
@@ -11561,6 +11400,7 @@
"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"
},
@@ -11616,6 +11456,19 @@
"integrity": "sha512-EIHvdY5bPLuWForiR/AN2Bxngzpuwn1is4asboytXtpTgsArc+WmSJKVLlhdh71u7jFcryDqB2A8lQvj78MkyQ==",
"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/temp-dir": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz",
@@ -11684,74 +11537,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/test-exclude": {
- "version": "7.0.1",
- "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz",
- "integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "@istanbuljs/schema": "^0.1.2",
- "glob": "^10.4.1",
- "minimatch": "^9.0.4"
- },
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/test-exclude/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/test-exclude/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/test-exclude/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/text-table": {
- "version": "0.2.0",
- "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
- "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==",
- "dev": true
- },
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
@@ -11820,30 +11605,10 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
- "node_modules/tinypool": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
- "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": "^18.0.0 || >=20.0.0"
- }
- },
"node_modules/tinyrainbow": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
- "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=14.0.0"
- }
- },
- "node_modules/tinyspy": {
- "version": "4.0.3",
- "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.3.tgz",
- "integrity": "sha512-t2T/WLB2WRgZ9EpE4jgPJ9w+i66UZfDc8wHh0xrwiRNN+UwH98GIJkTeZqX9rg0i0ptwzqW+uYeIF0T4F8LR7A==",
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
+ "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -11851,22 +11616,22 @@
}
},
"node_modules/tldts": {
- "version": "6.1.72",
- "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.72.tgz",
- "integrity": "sha512-QNtgIqSUb9o2CoUjX9T5TwaIvUUJFU1+12PJkgt42DFV2yf9J6549yTF2uGloQsJ/JOC8X+gIB81ind97hRiIQ==",
+ "version": "7.0.27",
+ "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.27.tgz",
+ "integrity": "sha512-I4FZcVFcqCRuT0ph6dCDpPuO4Xgzvh+spkcTr1gK7peIvxWauoloVO0vuy1FQnijT63ss6AsHB6+OIM4aXHbPg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "tldts-core": "^6.1.72"
+ "tldts-core": "^7.0.27"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
- "version": "6.1.72",
- "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.72.tgz",
- "integrity": "sha512-FW3H9aCaGTJ8l8RVCR3EX8GxsxDbQXuwetwwgXA2chYdsX+NY1ytCBl61narjjehWmCw92tc1AxlcY3668CU8g==",
+ "version": "7.0.27",
+ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.27.tgz",
+ "integrity": "sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg==",
"dev": true,
"license": "MIT"
},
@@ -11900,41 +11665,42 @@
}
},
"node_modules/tough-cookie": {
- "version": "5.1.0",
- "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.0.tgz",
- "integrity": "sha512-rvZUv+7MoBYTiDmFPBrhL7Ujx9Sk+q9wwm22x8c8T5IJaR+Wsyc7TNxbVxo84kZoRJZZMazowFLqpankBEQrGg==",
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz",
+ "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
- "tldts": "^6.1.32"
+ "tldts": "^7.0.5"
},
"engines": {
"node": ">=16"
}
},
"node_modules/tr46": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.0.0.tgz",
- "integrity": "sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g==",
+ "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": ">=18"
+ "node": ">=20"
}
},
"node_modules/ts-api-utils": {
- "version": "1.3.0",
- "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz",
- "integrity": "sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==",
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
+ "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==",
"dev": true,
"license": "MIT",
"engines": {
- "node": ">=16"
+ "node": ">=18.12"
},
"peerDependencies": {
- "typescript": ">=4.2.0"
+ "typescript": ">=4.8.4"
}
},
"node_modules/tslib": {
@@ -11943,33 +11709,6 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
- "node_modules/tsutils": {
- "version": "3.21.0",
- "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz",
- "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==",
- "dev": true,
- "dependencies": {
- "tslib": "^1.8.1"
- },
- "engines": {
- "node": ">= 6"
- },
- "peerDependencies": {
- "typescript": ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta"
- }
- },
- "node_modules/tsutils/node_modules/tslib": {
- "version": "1.14.1",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
- "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
- "dev": true
- },
- "node_modules/turbo-stream": {
- "version": "2.4.0",
- "resolved": "https://registry.npmjs.org/turbo-stream/-/turbo-stream-2.4.0.tgz",
- "integrity": "sha512-FHncC10WpBd2eOmGwpmQsWLDoK4cqsA/UT/GqNoaKOQnT8uzhtCbg3EoUDMvqpOSAI0S26mr0rkjzbOO6S3v1g==",
- "license": "ISC"
- },
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@@ -12073,10 +11812,11 @@
}
},
"node_modules/typescript": {
- "version": "5.4.4",
- "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.4.tgz",
- "integrity": "sha512-dGE2Vv8cpVvw28v8HCPqyb08EzbBURxDpuhJvTrusShUfGnhHBafDsLdS1EhhxyL6BJQE+2cT3dDPAv+MQ6oLw==",
+ "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"
@@ -12085,6 +11825,30 @@
"node": ">=14.17"
}
},
+ "node_modules/typescript-eslint": {
+ "version": "8.57.2",
+ "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.57.2.tgz",
+ "integrity": "sha512-VEPQ0iPgWO/sBaZOU1xo4nuNdODVOajPnTIbog2GKYr31nIlZ0fWPoCQgGfF3ETyBl1vn63F/p50Um9Z4J8O8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/eslint-plugin": "8.57.2",
+ "@typescript-eslint/parser": "8.57.2",
+ "@typescript-eslint/typescript-estree": "8.57.2",
+ "@typescript-eslint/utils": "8.57.2"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
"node_modules/unbox-primitive": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
@@ -12120,10 +11884,20 @@
"url": "https://github.com/sponsors/antfu"
}
},
+ "node_modules/undici": {
+ "version": "7.24.6",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.6.tgz",
+ "integrity": "sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.18.1"
+ }
+ },
"node_modules/undici-types": {
- "version": "6.21.0",
- "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
- "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "version": "7.18.2",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
+ "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"dev": true,
"license": "MIT"
},
@@ -12152,9 +11926,9 @@
}
},
"node_modules/unicode-match-property-value-ecmascript": {
- "version": "2.2.0",
- "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.0.tgz",
- "integrity": "sha512-4IehN3V/+kkr5YeSSDDQG8QLqO26XpL2XP3GQtqwlT/QYSECAwFztxVHjlbh0+gjJ3XmNLS0zDsbgs9jWKExLg==",
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.1.tgz",
+ "integrity": "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -12162,9 +11936,9 @@
}
},
"node_modules/unicode-property-aliases-ecmascript": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz",
- "integrity": "sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w==",
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz",
+ "integrity": "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -12197,14 +11971,14 @@
"node": ">=8"
}
},
- "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==",
+ "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",
- "engines": {
- "node": ">= 4.0.0"
+ "funding": {
+ "url": "https://github.com/sponsors/kettanaito"
}
},
"node_modules/upath": {
@@ -12219,9 +11993,9 @@
}
},
"node_modules/update-browserslist-db": {
- "version": "1.1.3",
- "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",
- "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==",
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
+ "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
"dev": true,
"funding": [
{
@@ -12254,21 +12028,11 @@
"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==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "querystringify": "^2.1.1",
- "requires-port": "^1.0.0"
- }
- },
"node_modules/use-callback-ref": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
@@ -12357,6 +12121,16 @@
}
}
},
+ "node_modules/use-sync-external-store": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
+ "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@@ -12372,10 +12146,11 @@
"license": "MIT"
},
"node_modules/victory-vendor": {
- "version": "36.9.2",
- "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
- "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==",
+ "version": "37.3.6",
+ "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
+ "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"dev": true,
+ "license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
@@ -12394,13 +12169,13 @@
}
},
"node_modules/vite": {
- "version": "7.2.7",
- "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.7.tgz",
- "integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==",
+ "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.25.0",
+ "esbuild": "^0.27.0",
"fdir": "^6.5.0",
"picomatch": "^4.0.3",
"postcss": "^8.5.6",
@@ -12438,88 +12213,40 @@
},
"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-node": {
- "version": "3.2.4",
- "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz",
- "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "cac": "^6.7.14",
- "debug": "^4.4.1",
- "es-module-lexer": "^1.7.0",
- "pathe": "^2.0.3",
- "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
- },
- "bin": {
- "vite-node": "vite-node.mjs"
- },
- "engines": {
- "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
- }
- },
- "node_modules/vite-node/node_modules/debug": {
- "version": "4.4.1",
- "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
- "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "ms": "^2.1.3"
- },
- "engines": {
- "node": ">=6.0"
- },
- "peerDependenciesMeta": {
- "supports-color": {
- "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-node/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==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/vite-plugin-checker": {
- "version": "0.10.3",
- "resolved": "https://registry.npmjs.org/vite-plugin-checker/-/vite-plugin-checker-0.10.3.tgz",
- "integrity": "sha512-f4sekUcDPF+T+GdbbE8idb1i2YplBAoH+SfRS0e/WRBWb2rYb1Jf5Pimll0Rj+3JgIYWwG2K5LtBPCXxoibkLg==",
+ "version": "0.12.0",
+ "resolved": "https://registry.npmjs.org/vite-plugin-checker/-/vite-plugin-checker-0.12.0.tgz",
+ "integrity": "sha512-CmdZdDOGss7kdQwv73UyVgLPv0FVYe5czAgnmRX2oKljgEvSrODGuClaV3PDR2+3ou7N/OKGauDDBjy2MB07Rg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -12528,22 +12255,22 @@
"npm-run-path": "^6.0.0",
"picocolors": "^1.1.1",
"picomatch": "^4.0.3",
- "strip-ansi": "^7.1.0",
"tiny-invariant": "^1.3.3",
- "tinyglobby": "^0.2.14",
+ "tinyglobby": "^0.2.15",
"vscode-uri": "^3.1.0"
},
"engines": {
- "node": ">=14.16"
+ "node": ">=16.11"
},
"peerDependencies": {
"@biomejs/biome": ">=1.7",
- "eslint": ">=7",
+ "eslint": ">=9.39.1",
"meow": "^13.2.0",
"optionator": "^0.9.4",
+ "oxlint": ">=1",
"stylelint": ">=16",
"typescript": "*",
- "vite": ">=2.0.0",
+ "vite": ">=5.4.21",
"vls": "*",
"vti": "*",
"vue-tsc": "~2.2.10 || ^3.0.0"
@@ -12561,6 +12288,9 @@
"optionator": {
"optional": true
},
+ "oxlint": {
+ "optional": true
+ },
"stylelint": {
"optional": true
},
@@ -12578,35 +12308,6 @@
}
}
},
- "node_modules/vite-plugin-checker/node_modules/ansi-regex": {
- "version": "6.1.0",
- "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz",
- "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/chalk/ansi-regex?sponsor=1"
- }
- },
- "node_modules/vite-plugin-checker/node_modules/chokidar": {
- "version": "4.0.3",
- "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
- "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "readdirp": "^4.0.1"
- },
- "engines": {
- "node": ">= 14.16.0"
- },
- "funding": {
- "url": "https://paulmillr.com/funding/"
- }
- },
"node_modules/vite-plugin-checker/node_modules/npm-run-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz",
@@ -12650,48 +12351,18 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
- "node_modules/vite-plugin-checker/node_modules/readdirp": {
- "version": "4.1.2",
- "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
- "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 14.18.0"
- },
- "funding": {
- "type": "individual",
- "url": "https://paulmillr.com/funding/"
- }
- },
- "node_modules/vite-plugin-checker/node_modules/strip-ansi": {
- "version": "7.1.0",
- "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
- "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
- "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/vite-plugin-pwa": {
- "version": "1.0.3",
- "resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-1.0.3.tgz",
- "integrity": "sha512-/OpqIpUldALGxcsEnv/ekQiQ5xHkQ53wcoN5ewX4jiIDNGs3W+eNcI1WYZeyOLmzoEjg09D7aX0O89YGjen1aw==",
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-1.2.0.tgz",
+ "integrity": "sha512-a2xld+SJshT9Lgcv8Ji4+srFJL4k/1bVbd1x06JIkvecpQkwkvCncD1+gSzcdm3s+owWLpMJerG3aN5jupJEVw==",
"dev": true,
"license": "MIT",
"dependencies": {
"debug": "^4.3.6",
"pretty-bytes": "^6.1.1",
"tinyglobby": "^0.2.10",
- "workbox-build": "^7.3.0",
- "workbox-window": "^7.3.0"
+ "workbox-build": "^7.4.0",
+ "workbox-window": "^7.4.0"
},
"engines": {
"node": ">=16.0.0"
@@ -12702,8 +12373,8 @@
"peerDependencies": {
"@vite-pwa/assets-generator": "^1.0.0",
"vite": "^3.1.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0",
- "workbox-build": "^7.3.0",
- "workbox-window": "^7.3.0"
+ "workbox-build": "^7.4.0",
+ "workbox-window": "^7.4.0"
},
"peerDependenciesMeta": {
"@vite-pwa/assets-generator": {
@@ -12768,65 +12439,71 @@
}
},
"node_modules/vitest": {
- "version": "3.2.4",
- "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
- "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@types/chai": "^5.2.2",
- "@vitest/expect": "3.2.4",
- "@vitest/mocker": "3.2.4",
- "@vitest/pretty-format": "^3.2.4",
- "@vitest/runner": "3.2.4",
- "@vitest/snapshot": "3.2.4",
- "@vitest/spy": "3.2.4",
- "@vitest/utils": "3.2.4",
- "chai": "^5.2.0",
- "debug": "^4.4.1",
- "expect-type": "^1.2.1",
- "magic-string": "^0.30.17",
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.2.tgz",
+ "integrity": "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/expect": "4.1.2",
+ "@vitest/mocker": "4.1.2",
+ "@vitest/pretty-format": "4.1.2",
+ "@vitest/runner": "4.1.2",
+ "@vitest/snapshot": "4.1.2",
+ "@vitest/spy": "4.1.2",
+ "@vitest/utils": "4.1.2",
+ "es-module-lexer": "^2.0.0",
+ "expect-type": "^1.3.0",
+ "magic-string": "^0.30.21",
+ "obug": "^2.1.1",
"pathe": "^2.0.3",
- "picomatch": "^4.0.2",
- "std-env": "^3.9.0",
+ "picomatch": "^4.0.3",
+ "std-env": "^4.0.0-rc.1",
"tinybench": "^2.9.0",
- "tinyexec": "^0.3.2",
- "tinyglobby": "^0.2.14",
- "tinypool": "^1.1.1",
- "tinyrainbow": "^2.0.0",
- "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0",
- "vite-node": "3.2.4",
+ "tinyexec": "^1.0.2",
+ "tinyglobby": "^0.2.15",
+ "tinyrainbow": "^3.1.0",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
- "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
- "@types/debug": "^4.1.12",
- "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
- "@vitest/browser": "3.2.4",
- "@vitest/ui": "3.2.4",
+ "@opentelemetry/api": "^1.9.0",
+ "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
+ "@vitest/browser-playwright": "4.1.2",
+ "@vitest/browser-preview": "4.1.2",
+ "@vitest/browser-webdriverio": "4.1.2",
+ "@vitest/ui": "4.1.2",
"happy-dom": "*",
- "jsdom": "*"
+ "jsdom": "*",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
- "@types/debug": {
+ "@opentelemetry/api": {
"optional": true
},
"@types/node": {
"optional": true
},
- "@vitest/browser": {
+ "@vitest/browser-playwright": {
+ "optional": true
+ },
+ "@vitest/browser-preview": {
+ "optional": true
+ },
+ "@vitest/browser-webdriverio": {
"optional": true
},
"@vitest/ui": {
@@ -12837,34 +12514,12 @@
},
"jsdom": {
"optional": true
+ },
+ "vite": {
+ "optional": false
}
}
},
- "node_modules/vitest/node_modules/debug": {
- "version": "4.4.1",
- "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
- "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "ms": "^2.1.3"
- },
- "engines": {
- "node": ">=6.0"
- },
- "peerDependenciesMeta": {
- "supports-color": {
- "optional": true
- }
- }
- },
- "node_modules/vitest/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==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/vitest/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
@@ -12878,6 +12533,16 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/vitest/node_modules/tinyexec": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz",
+ "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/vscode-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz",
@@ -12898,47 +12563,38 @@
}
},
"node_modules/webidl-conversions": {
- "version": "7.0.0",
- "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
- "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
- "dev": true,
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/whatwg-encoding": {
- "version": "3.1.1",
- "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
- "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
+ "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,
- "dependencies": {
- "iconv-lite": "0.6.3"
- },
+ "license": "BSD-2-Clause",
"engines": {
- "node": ">=18"
+ "node": ">=20"
}
},
"node_modules/whatwg-mimetype": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
- "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
+ "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
"dev": true,
+ "license": "MIT",
"engines": {
- "node": ">=18"
+ "node": ">=20"
}
},
"node_modules/whatwg-url": {
- "version": "14.1.0",
- "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.1.0.tgz",
- "integrity": "sha512-jlf/foYIKywAt3x/XWKZ/3rz8OSJPiWktjmk891alJUEjiVxKX9LEO92qH3hv4aJ0mN3MWPvGMCy8jQi95xK4w==",
+ "version": "16.0.1",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
+ "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "tr46": "^5.0.0",
- "webidl-conversions": "^7.0.0"
+ "@exodus/bytes": "^1.11.0",
+ "tr46": "^6.0.0",
+ "webidl-conversions": "^8.0.1"
},
"engines": {
- "node": ">=18"
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/which": {
@@ -13024,9 +12680,9 @@
}
},
"node_modules/which-typed-array": {
- "version": "1.1.19",
- "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz",
- "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==",
+ "version": "1.1.20",
+ "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz",
+ "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -13073,30 +12729,30 @@
}
},
"node_modules/workbox-background-sync": {
- "version": "7.3.0",
- "resolved": "https://registry.npmjs.org/workbox-background-sync/-/workbox-background-sync-7.3.0.tgz",
- "integrity": "sha512-PCSk3eK7Mxeuyatb22pcSx9dlgWNv3+M8PqPaYDokks8Y5/FX4soaOqj3yhAZr5k6Q5JWTOMYgaJBpbw11G9Eg==",
+ "version": "7.4.0",
+ "resolved": "https://registry.npmjs.org/workbox-background-sync/-/workbox-background-sync-7.4.0.tgz",
+ "integrity": "sha512-8CB9OxKAgKZKyNMwfGZ1XESx89GryWTfI+V5yEj8sHjFH8MFelUwYXEyldEK6M6oKMmn807GoJFUEA1sC4XS9w==",
"dev": true,
"license": "MIT",
"dependencies": {
"idb": "^7.0.1",
- "workbox-core": "7.3.0"
+ "workbox-core": "7.4.0"
}
},
"node_modules/workbox-broadcast-update": {
- "version": "7.3.0",
- "resolved": "https://registry.npmjs.org/workbox-broadcast-update/-/workbox-broadcast-update-7.3.0.tgz",
- "integrity": "sha512-T9/F5VEdJVhwmrIAE+E/kq5at2OY6+OXXgOWQevnubal6sO92Gjo24v6dCVwQiclAF5NS3hlmsifRrpQzZCdUA==",
+ "version": "7.4.0",
+ "resolved": "https://registry.npmjs.org/workbox-broadcast-update/-/workbox-broadcast-update-7.4.0.tgz",
+ "integrity": "sha512-+eZQwoktlvo62cI0b+QBr40v5XjighxPq3Fzo9AWMiAosmpG5gxRHgTbGGhaJv/q/MFVxwFNGh/UwHZ/8K88lA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "workbox-core": "7.3.0"
+ "workbox-core": "7.4.0"
}
},
"node_modules/workbox-build": {
- "version": "7.3.0",
- "resolved": "https://registry.npmjs.org/workbox-build/-/workbox-build-7.3.0.tgz",
- "integrity": "sha512-JGL6vZTPlxnlqZRhR/K/msqg3wKP+m0wfEUVosK7gsYzSgeIxvZLi1ViJJzVL7CEeI8r7rGFV973RiEqkP3lWQ==",
+ "version": "7.4.0",
+ "resolved": "https://registry.npmjs.org/workbox-build/-/workbox-build-7.4.0.tgz",
+ "integrity": "sha512-Ntk1pWb0caOFIvwz/hfgrov/OJ45wPEhI5PbTywQcYjyZiVhT3UrwwUPl6TRYbTm4moaFYithYnl1lvZ8UjxcA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -13113,33 +12769,33 @@
"common-tags": "^1.8.0",
"fast-json-stable-stringify": "^2.1.0",
"fs-extra": "^9.0.1",
- "glob": "^7.1.6",
+ "glob": "^11.0.1",
"lodash": "^4.17.20",
"pretty-bytes": "^5.3.0",
- "rollup": "^2.43.1",
+ "rollup": "^2.79.2",
"source-map": "^0.8.0-beta.0",
"stringify-object": "^3.3.0",
"strip-comments": "^2.0.1",
"tempy": "^0.6.0",
"upath": "^1.2.0",
- "workbox-background-sync": "7.3.0",
- "workbox-broadcast-update": "7.3.0",
- "workbox-cacheable-response": "7.3.0",
- "workbox-core": "7.3.0",
- "workbox-expiration": "7.3.0",
- "workbox-google-analytics": "7.3.0",
- "workbox-navigation-preload": "7.3.0",
- "workbox-precaching": "7.3.0",
- "workbox-range-requests": "7.3.0",
- "workbox-recipes": "7.3.0",
- "workbox-routing": "7.3.0",
- "workbox-strategies": "7.3.0",
- "workbox-streams": "7.3.0",
- "workbox-sw": "7.3.0",
- "workbox-window": "7.3.0"
+ "workbox-background-sync": "7.4.0",
+ "workbox-broadcast-update": "7.4.0",
+ "workbox-cacheable-response": "7.4.0",
+ "workbox-core": "7.4.0",
+ "workbox-expiration": "7.4.0",
+ "workbox-google-analytics": "7.4.0",
+ "workbox-navigation-preload": "7.4.0",
+ "workbox-precaching": "7.4.0",
+ "workbox-range-requests": "7.4.0",
+ "workbox-recipes": "7.4.0",
+ "workbox-routing": "7.4.0",
+ "workbox-strategies": "7.4.0",
+ "workbox-streams": "7.4.0",
+ "workbox-sw": "7.4.0",
+ "workbox-window": "7.4.0"
},
"engines": {
- "node": ">=16.0.0"
+ "node": ">=20.0.0"
}
},
"node_modules/workbox-build/node_modules/@apideck/better-ajv-errors": {
@@ -13160,6 +12816,16 @@
"ajv": ">=8"
}
},
+ "node_modules/workbox-build/node_modules/@isaacs/cliui": {
+ "version": "9.0.0",
+ "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz",
+ "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/workbox-build/node_modules/@rollup/plugin-babel": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz",
@@ -13224,9 +12890,9 @@
"license": "MIT"
},
"node_modules/workbox-build/node_modules/ajv": {
- "version": "8.17.1",
- "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
- "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
+ "version": "8.18.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
+ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -13247,20 +12913,45 @@
"dev": true,
"license": "MIT"
},
- "node_modules/workbox-build/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==",
+ "node_modules/workbox-build/node_modules/glob": {
+ "version": "11.1.0",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz",
+ "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==",
+ "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
"dev": true,
- "license": "MIT",
+ "license": "BlueOak-1.0.0",
"dependencies": {
- "at-least-node": "^1.0.0",
- "graceful-fs": "^4.2.0",
- "jsonfile": "^6.0.1",
- "universalify": "^2.0.0"
+ "foreground-child": "^3.3.1",
+ "jackspeak": "^4.1.1",
+ "minimatch": "^10.1.1",
+ "minipass": "^7.1.2",
+ "package-json-from-dist": "^1.0.0",
+ "path-scurry": "^2.0.0"
+ },
+ "bin": {
+ "glob": "dist/esm/bin.mjs"
},
"engines": {
- "node": ">=10"
+ "node": "20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/workbox-build/node_modules/jackspeak": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz",
+ "integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "@isaacs/cliui": "^9.0.0"
+ },
+ "engines": {
+ "node": "20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/workbox-build/node_modules/json-schema-traverse": {
@@ -13270,6 +12961,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/workbox-build/node_modules/lru-cache": {
+ "version": "11.2.6",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz",
+ "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
"node_modules/workbox-build/node_modules/magic-string": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz",
@@ -13280,6 +12981,23 @@
"sourcemap-codec": "^1.4.8"
}
},
+ "node_modules/workbox-build/node_modules/path-scurry": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz",
+ "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "lru-cache": "^11.0.0",
+ "minipass": "^7.1.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/workbox-build/node_modules/pretty-bytes": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz",
@@ -13294,9 +13012,9 @@
}
},
"node_modules/workbox-build/node_modules/rollup": {
- "version": "2.79.2",
- "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.2.tgz",
- "integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==",
+ "version": "2.80.0",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.80.0.tgz",
+ "integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==",
"dev": true,
"license": "MIT",
"bin": {
@@ -13309,160 +13027,143 @@
"fsevents": "~2.3.2"
}
},
- "node_modules/workbox-build/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/workbox-cacheable-response": {
- "version": "7.3.0",
- "resolved": "https://registry.npmjs.org/workbox-cacheable-response/-/workbox-cacheable-response-7.3.0.tgz",
- "integrity": "sha512-eAFERIg6J2LuyELhLlmeRcJFa5e16Mj8kL2yCDbhWE+HUun9skRQrGIFVUagqWj4DMaaPSMWfAolM7XZZxNmxA==",
+ "version": "7.4.0",
+ "resolved": "https://registry.npmjs.org/workbox-cacheable-response/-/workbox-cacheable-response-7.4.0.tgz",
+ "integrity": "sha512-0Fb8795zg/x23ISFkAc7lbWes6vbw34DGFIMw31cwuHPgDEC/5EYm6m/ZkylLX0EnEbbOyOCLjKgFS/Z5g0HeQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "workbox-core": "7.3.0"
+ "workbox-core": "7.4.0"
}
},
"node_modules/workbox-core": {
- "version": "7.3.0",
- "resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-7.3.0.tgz",
- "integrity": "sha512-Z+mYrErfh4t3zi7NVTvOuACB0A/jA3bgxUN3PwtAVHvfEsZxV9Iju580VEETug3zYJRc0Dmii/aixI/Uxj8fmw==",
+ "version": "7.4.0",
+ "resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-7.4.0.tgz",
+ "integrity": "sha512-6BMfd8tYEnN4baG4emG9U0hdXM4gGuDU3ectXuVHnj71vwxTFI7WOpQJC4siTOlVtGqCUtj0ZQNsrvi6kZZTAQ==",
"dev": true,
"license": "MIT"
},
"node_modules/workbox-expiration": {
- "version": "7.3.0",
- "resolved": "https://registry.npmjs.org/workbox-expiration/-/workbox-expiration-7.3.0.tgz",
- "integrity": "sha512-lpnSSLp2BM+K6bgFCWc5bS1LR5pAwDWbcKt1iL87/eTSJRdLdAwGQznZE+1czLgn/X05YChsrEegTNxjM067vQ==",
+ "version": "7.4.0",
+ "resolved": "https://registry.npmjs.org/workbox-expiration/-/workbox-expiration-7.4.0.tgz",
+ "integrity": "sha512-V50p4BxYhtA80eOvulu8xVfPBgZbkxJ1Jr8UUn0rvqjGhLDqKNtfrDfjJKnLz2U8fO2xGQJTx/SKXNTzHOjnHw==",
"dev": true,
"license": "MIT",
"dependencies": {
"idb": "^7.0.1",
- "workbox-core": "7.3.0"
+ "workbox-core": "7.4.0"
}
},
"node_modules/workbox-google-analytics": {
- "version": "7.3.0",
- "resolved": "https://registry.npmjs.org/workbox-google-analytics/-/workbox-google-analytics-7.3.0.tgz",
- "integrity": "sha512-ii/tSfFdhjLHZ2BrYgFNTrb/yk04pw2hasgbM70jpZfLk0vdJAXgaiMAWsoE+wfJDNWoZmBYY0hMVI0v5wWDbg==",
+ "version": "7.4.0",
+ "resolved": "https://registry.npmjs.org/workbox-google-analytics/-/workbox-google-analytics-7.4.0.tgz",
+ "integrity": "sha512-MVPXQslRF6YHkzGoFw1A4GIB8GrKym/A5+jYDUSL+AeJw4ytQGrozYdiZqUW1TPQHW8isBCBtyFJergUXyNoWQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "workbox-background-sync": "7.3.0",
- "workbox-core": "7.3.0",
- "workbox-routing": "7.3.0",
- "workbox-strategies": "7.3.0"
+ "workbox-background-sync": "7.4.0",
+ "workbox-core": "7.4.0",
+ "workbox-routing": "7.4.0",
+ "workbox-strategies": "7.4.0"
}
},
"node_modules/workbox-navigation-preload": {
- "version": "7.3.0",
- "resolved": "https://registry.npmjs.org/workbox-navigation-preload/-/workbox-navigation-preload-7.3.0.tgz",
- "integrity": "sha512-fTJzogmFaTv4bShZ6aA7Bfj4Cewaq5rp30qcxl2iYM45YD79rKIhvzNHiFj1P+u5ZZldroqhASXwwoyusnr2cg==",
+ "version": "7.4.0",
+ "resolved": "https://registry.npmjs.org/workbox-navigation-preload/-/workbox-navigation-preload-7.4.0.tgz",
+ "integrity": "sha512-etzftSgdQfjMcfPgbfaZCfM2QuR1P+4o8uCA2s4rf3chtKTq/Om7g/qvEOcZkG6v7JZOSOxVYQiOu6PbAZgU6w==",
"dev": true,
"license": "MIT",
"dependencies": {
- "workbox-core": "7.3.0"
+ "workbox-core": "7.4.0"
}
},
"node_modules/workbox-precaching": {
- "version": "7.3.0",
- "resolved": "https://registry.npmjs.org/workbox-precaching/-/workbox-precaching-7.3.0.tgz",
- "integrity": "sha512-ckp/3t0msgXclVAYaNndAGeAoWQUv7Rwc4fdhWL69CCAb2UHo3Cef0KIUctqfQj1p8h6aGyz3w8Cy3Ihq9OmIw==",
+ "version": "7.4.0",
+ "resolved": "https://registry.npmjs.org/workbox-precaching/-/workbox-precaching-7.4.0.tgz",
+ "integrity": "sha512-VQs37T6jDqf1rTxUJZXRl3yjZMf5JX/vDPhmx2CPgDDKXATzEoqyRqhYnRoxl6Kr0rqaQlp32i9rtG5zTzIlNg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "workbox-core": "7.3.0",
- "workbox-routing": "7.3.0",
- "workbox-strategies": "7.3.0"
+ "workbox-core": "7.4.0",
+ "workbox-routing": "7.4.0",
+ "workbox-strategies": "7.4.0"
}
},
"node_modules/workbox-range-requests": {
- "version": "7.3.0",
- "resolved": "https://registry.npmjs.org/workbox-range-requests/-/workbox-range-requests-7.3.0.tgz",
- "integrity": "sha512-EyFmM1KpDzzAouNF3+EWa15yDEenwxoeXu9bgxOEYnFfCxns7eAxA9WSSaVd8kujFFt3eIbShNqa4hLQNFvmVQ==",
+ "version": "7.4.0",
+ "resolved": "https://registry.npmjs.org/workbox-range-requests/-/workbox-range-requests-7.4.0.tgz",
+ "integrity": "sha512-3Vq854ZNuP6Y0KZOQWLaLC9FfM7ZaE+iuQl4VhADXybwzr4z/sMmnLgTeUZLq5PaDlcJBxYXQ3U91V7dwAIfvw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "workbox-core": "7.3.0"
+ "workbox-core": "7.4.0"
}
},
"node_modules/workbox-recipes": {
- "version": "7.3.0",
- "resolved": "https://registry.npmjs.org/workbox-recipes/-/workbox-recipes-7.3.0.tgz",
- "integrity": "sha512-BJro/MpuW35I/zjZQBcoxsctgeB+kyb2JAP5EB3EYzePg8wDGoQuUdyYQS+CheTb+GhqJeWmVs3QxLI8EBP1sg==",
+ "version": "7.4.0",
+ "resolved": "https://registry.npmjs.org/workbox-recipes/-/workbox-recipes-7.4.0.tgz",
+ "integrity": "sha512-kOkWvsAn4H8GvAkwfJTbwINdv4voFoiE9hbezgB1sb/0NLyTG4rE7l6LvS8lLk5QIRIto+DjXLuAuG3Vmt3cxQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "workbox-cacheable-response": "7.3.0",
- "workbox-core": "7.3.0",
- "workbox-expiration": "7.3.0",
- "workbox-precaching": "7.3.0",
- "workbox-routing": "7.3.0",
- "workbox-strategies": "7.3.0"
+ "workbox-cacheable-response": "7.4.0",
+ "workbox-core": "7.4.0",
+ "workbox-expiration": "7.4.0",
+ "workbox-precaching": "7.4.0",
+ "workbox-routing": "7.4.0",
+ "workbox-strategies": "7.4.0"
}
},
"node_modules/workbox-routing": {
- "version": "7.3.0",
- "resolved": "https://registry.npmjs.org/workbox-routing/-/workbox-routing-7.3.0.tgz",
- "integrity": "sha512-ZUlysUVn5ZUzMOmQN3bqu+gK98vNfgX/gSTZ127izJg/pMMy4LryAthnYtjuqcjkN4HEAx1mdgxNiKJMZQM76A==",
+ "version": "7.4.0",
+ "resolved": "https://registry.npmjs.org/workbox-routing/-/workbox-routing-7.4.0.tgz",
+ "integrity": "sha512-C/ooj5uBWYAhAqwmU8HYQJdOjjDKBp9MzTQ+otpMmd+q0eF59K+NuXUek34wbL0RFrIXe/KKT+tUWcZcBqxbHQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "workbox-core": "7.3.0"
+ "workbox-core": "7.4.0"
}
},
"node_modules/workbox-strategies": {
- "version": "7.3.0",
- "resolved": "https://registry.npmjs.org/workbox-strategies/-/workbox-strategies-7.3.0.tgz",
- "integrity": "sha512-tmZydug+qzDFATwX7QiEL5Hdf7FrkhjaF9db1CbB39sDmEZJg3l9ayDvPxy8Y18C3Y66Nrr9kkN1f/RlkDgllg==",
+ "version": "7.4.0",
+ "resolved": "https://registry.npmjs.org/workbox-strategies/-/workbox-strategies-7.4.0.tgz",
+ "integrity": "sha512-T4hVqIi5A4mHi92+5EppMX3cLaVywDp8nsyUgJhOZxcfSV/eQofcOA6/EMo5rnTNmNTpw0rUgjAI6LaVullPpg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "workbox-core": "7.3.0"
+ "workbox-core": "7.4.0"
}
},
"node_modules/workbox-streams": {
- "version": "7.3.0",
- "resolved": "https://registry.npmjs.org/workbox-streams/-/workbox-streams-7.3.0.tgz",
- "integrity": "sha512-SZnXucyg8x2Y61VGtDjKPO5EgPUG5NDn/v86WYHX+9ZqvAsGOytP0Jxp1bl663YUuMoXSAtsGLL+byHzEuMRpw==",
+ "version": "7.4.0",
+ "resolved": "https://registry.npmjs.org/workbox-streams/-/workbox-streams-7.4.0.tgz",
+ "integrity": "sha512-QHPBQrey7hQbnTs5GrEVoWz7RhHJXnPT+12qqWM378orDMo5VMJLCkCM1cnCk+8Eq92lccx/VgRZ7WAzZWbSLg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "workbox-core": "7.3.0",
- "workbox-routing": "7.3.0"
+ "workbox-core": "7.4.0",
+ "workbox-routing": "7.4.0"
}
},
"node_modules/workbox-sw": {
- "version": "7.3.0",
- "resolved": "https://registry.npmjs.org/workbox-sw/-/workbox-sw-7.3.0.tgz",
- "integrity": "sha512-aCUyoAZU9IZtH05mn0ACUpyHzPs0lMeJimAYkQkBsOWiqaJLgusfDCR+yllkPkFRxWpZKF8vSvgHYeG7LwhlmA==",
+ "version": "7.4.0",
+ "resolved": "https://registry.npmjs.org/workbox-sw/-/workbox-sw-7.4.0.tgz",
+ "integrity": "sha512-ltU+Kr3qWR6BtbdlMnCjobZKzeV1hN+S6UvDywBrwM19TTyqA03X66dzw1tEIdJvQ4lYKkBFox6IAEhoSEZ8Xw==",
"dev": true,
"license": "MIT"
},
"node_modules/workbox-window": {
- "version": "7.3.0",
- "resolved": "https://registry.npmjs.org/workbox-window/-/workbox-window-7.3.0.tgz",
- "integrity": "sha512-qW8PDy16OV1UBaUNGlTVcepzrlzyzNW/ZJvFQQs2j2TzGsg6IKjcpZC1RSquqQnTOafl5pCj5bGfAHlCjOOjdA==",
+ "version": "7.4.0",
+ "resolved": "https://registry.npmjs.org/workbox-window/-/workbox-window-7.4.0.tgz",
+ "integrity": "sha512-/bIYdBLAVsNR3v7gYGaV4pQW3M3kEPx5E8vDxGvxo6khTrGtSSCS7QiFKv9ogzBgZiy0OXLP9zO28U/1nF1mfw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/trusted-types": "^2.0.2",
- "workbox-core": "7.1.0"
+ "workbox-core": "7.4.0"
}
},
- "node_modules/workbox-window/node_modules/workbox-core": {
- "version": "7.1.0",
- "resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-7.1.0.tgz",
- "integrity": "sha512-5KB4KOY8rtL31nEF7BfvU7FMzKT4B5TkbYa2tzkS+Peqj0gayMT9SytSFtNzlrvMaWgv6y/yvP9C0IbpFjV30Q==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
@@ -13478,35 +13179,10 @@
"node": ">=8"
}
},
- "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
- },
"node_modules/ws": {
- "version": "8.17.1",
- "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
- "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
+ "version": "8.18.3",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
+ "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
@@ -13540,9 +13216,9 @@
"dev": true
},
"node_modules/xmlhttprequest-ssl": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz",
- "integrity": "sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==",
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
+ "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
"engines": {
"node": ">=0.4.0"
}
@@ -13564,16 +13240,19 @@
"dev": true
},
"node_modules/yaml": {
- "version": "2.7.1",
- "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.1.tgz",
- "integrity": "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==",
+ "version": "2.8.3",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
+ "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
"dev": true,
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
- "node": ">= 14"
+ "node": ">= 14.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/yargs": {
@@ -13629,6 +13308,29 @@
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
+ },
+ "node_modules/zod": {
+ "version": "4.3.6",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
+ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
+ "node_modules/zod-validation-error": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz",
+ "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "peerDependencies": {
+ "zod": "^3.25.0 || ^4.0.0"
+ }
}
}
}
diff --git a/frontend/package.json b/frontend/package.json
index 0650a45ad8..7204befda2 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -4,82 +4,87 @@
"description": "Bazarr is a companion application to Sonarr and Radarr. It manages and downloads subtitles based on your requirements. You define your preferences by TV show or movie and Bazarr takes care of everything for you.",
"repository": {
"type": "git",
- "url": "git+https://github.com/morpheus65535/bazarr.git"
+ "url": "git+https://github.com/LavX/bazarr.git"
},
- "author": "morpheus65535",
+ "author": "LavX",
"license": "GPL-3",
"bugs": {
- "url": "https://github.com/morpheus65535/bazarr/issues"
+ "url": "https://github.com/LavX/bazarr/issues"
},
"private": true,
"dependencies": {
- "@mantine/core": "^8.3.9",
- "@mantine/dropzone": "^8.3.9",
- "@mantine/form": "^8.3.9",
- "@mantine/hooks": "^8.3.9",
- "@mantine/modals": "^8.3.9",
- "@mantine/notifications": "^8.3.9",
- "@tanstack/react-query": "^5.64.1",
- "@tanstack/react-table": "^8.19.2",
- "axios": "^1.8.2",
+ "@fontsource-variable/geist": "^5.2.8",
+ "@mantine/core": "^8.3.16",
+ "@mantine/dropzone": "^8.3.16",
+ "@mantine/form": "^8.3.16",
+ "@mantine/hooks": "^8.3.16",
+ "@mantine/modals": "^8.3.16",
+ "@mantine/notifications": "^8.3.16",
+ "@tanstack/react-query": "^5.90.21",
+ "@tanstack/react-table": "^8.21.3",
+ "@tanstack/react-virtual": "^3.13.23",
+ "axios": "^1.13.6",
"braces": "^3.0.3",
- "react": "^19.2.3",
- "react-dom": "^19.2.3",
- "react-router": "^7.1.1",
+ "react": "^19.2.4",
+ "react-dom": "^19.2.4",
+ "react-router": "^7.13.1",
"react-timeago": "^8.3.0",
- "socket.io-client": "^4.7.5"
+ "socket.io-client": "^4.8.3"
},
"devDependencies": {
- "@fontsource/roboto": "^5.0.12",
- "@fortawesome/fontawesome-svg-core": "^7.1.0",
- "@fortawesome/free-brands-svg-icons": "^7.1.0",
- "@fortawesome/free-regular-svg-icons": "^7.1.0",
- "@fortawesome/free-solid-svg-icons": "^7.1.0",
- "@fortawesome/react-fontawesome": "^3.1.0",
- "@tanstack/react-query-devtools": "^5.40.1",
- "@testing-library/jest-dom": "^6.4.2",
- "@testing-library/react": "^16.1.0",
- "@testing-library/user-event": "^14.5.2",
- "@types/jest": "^29.5.12",
- "@types/lodash": "^4.17.1",
- "@types/node": "^22.14.1",
- "@types/react": "^19.2.3",
+ "@eslint/js": "^10.0.1",
+ "@fortawesome/fontawesome-svg-core": "^7.2.0",
+ "@fortawesome/free-brands-svg-icons": "^7.2.0",
+ "@fortawesome/free-regular-svg-icons": "^7.2.0",
+ "@fortawesome/free-solid-svg-icons": "^7.2.0",
+ "@fortawesome/react-fontawesome": "^3.2.0",
+ "@tanstack/react-query-devtools": "^5.91.3",
+ "@testing-library/dom": "^10.4.1",
+ "@testing-library/jest-dom": "^6.9.1",
+ "@testing-library/react": "^16.3.2",
+ "@testing-library/user-event": "^14.6.1",
+ "@types/jest": "^30.0.0",
+ "@types/lodash": "^4.17.24",
+ "@types/node": "^25.4.0",
+ "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
- "@typescript-eslint/eslint-plugin": "^7.16.0",
- "@typescript-eslint/parser": "^7.16.0",
- "@vite-pwa/assets-generator": "^1.0.0",
- "@vitejs/plugin-react": "^5.0.1",
- "@vitest/coverage-v8": "^3.2.4",
- "@vitest/ui": "^3.2.4",
- "clsx": "^2.1.0",
- "eslint": "^8.57.0",
- "eslint-plugin-react-hooks": "^4.6.0",
- "eslint-plugin-react-refresh": "^0.4.7",
- "eslint-plugin-simple-import-sort": "^12.1.0",
- "eslint-plugin-testing-library": "^6.2.0",
- "husky": "^9.0.11",
- "jsdom": "^26.0.0",
- "lodash": "^4.17.21",
- "msw": "^2.7.0",
+ "@typescript-eslint/eslint-plugin": "^8.57.0",
+ "@typescript-eslint/parser": "^8.57.0",
+ "@vite-pwa/assets-generator": "^1.0.2",
+ "@vitejs/plugin-react": "^5.1.4",
+ "@vitest/coverage-v8": "^4.0.18",
+ "@vitest/ui": "^4.0.18",
+ "clsx": "^2.1.1",
+ "eslint": "^10.0.3",
+ "eslint-plugin-react-hooks": "^7.0.1",
+ "eslint-plugin-react-refresh": "^0.5.2",
+ "eslint-plugin-simple-import-sort": "^12.1.1",
+ "eslint-plugin-testing-library": "^7.16.0",
+ "globals": "^17.4.0",
+ "husky": "^9.1.7",
+ "jsdom": "^28.1.0",
+ "lodash": "^4.17.23",
+ "msw": "^2.12.10",
"postcss-preset-mantine": "^1.18.0",
"postcss-simple-vars": "^7.0.1",
- "prettier": "^3.2.5",
- "prettier-plugin-organize-imports": "^3.2.4",
- "pretty-quick": "^4.0.0",
- "recharts": "^2.15.0",
- "sass-embedded": "^1.86.1",
- "typescript": "^5.4.4",
- "vite": "^7.1.3",
- "vite-plugin-checker": "^0.10.3",
- "vite-plugin-pwa": "^1.0.3",
- "vitest": "^3.2.4",
- "yaml": "^2.4.1"
+ "prettier": "^3.8.1",
+ "prettier-plugin-organize-imports": "^4.3.0",
+ "pretty-quick": "^4.2.2",
+ "recharts": "^3.8.0",
+ "sass-embedded": "^1.98.0",
+ "typescript": "^5.9.3",
+ "typescript-eslint": "^8.57.0",
+ "vite": "^7.3.1",
+ "vite-plugin-checker": "^0.12.0",
+ "vite-plugin-pwa": "^1.2.0",
+ "vitest": "^4.0.18",
+ "yaml": "^2.8.2"
},
"scripts": {
"build": "vite build",
"build:ci": "vite build -m development",
- "check": "eslint --ext .ts,.tsx src",
- "check:fix": "eslint --ext .ts,.tsx src --fix",
+ "check": "eslint src",
+ "check:fix": "eslint src --fix",
"check:ts": "tsc --noEmit --incremental false",
"check:fmt": "prettier -c .",
"coverage": "vitest run --coverage",
@@ -102,5 +107,11 @@
"last 1 firefox version",
"last 1 safari version"
]
+ },
+ "overrides": {
+ "serialize-javascript": "^7.0.4",
+ "eslint-plugin-react-hooks": {
+ "eslint": "$eslint"
+ }
}
}
diff --git a/frontend/public/images/android-chrome-96x96.png b/frontend/public/images/android-chrome-96x96.png
index a9227b96a8..53ca9176d2 100644
Binary files a/frontend/public/images/android-chrome-96x96.png and b/frontend/public/images/android-chrome-96x96.png differ
diff --git a/frontend/public/images/apple-touch-icon-180x180.png b/frontend/public/images/apple-touch-icon-180x180.png
index 01c19a6eeb..5054fb650e 100644
Binary files a/frontend/public/images/apple-touch-icon-180x180.png and b/frontend/public/images/apple-touch-icon-180x180.png differ
diff --git a/frontend/public/images/favicon-16x16.png b/frontend/public/images/favicon-16x16.png
index 03618a704f..f15db17621 100644
Binary files a/frontend/public/images/favicon-16x16.png and b/frontend/public/images/favicon-16x16.png differ
diff --git a/frontend/public/images/favicon-32x32.png b/frontend/public/images/favicon-32x32.png
index 85dbb8462e..d4f4f07a67 100644
Binary files a/frontend/public/images/favicon-32x32.png and b/frontend/public/images/favicon-32x32.png differ
diff --git a/frontend/public/images/favicon.ico b/frontend/public/images/favicon.ico
index 02c0a64122..149200567b 100644
Binary files a/frontend/public/images/favicon.ico and b/frontend/public/images/favicon.ico differ
diff --git a/frontend/public/images/logo.png b/frontend/public/images/logo.png
new file mode 100644
index 0000000000..d0d9c2d764
Binary files /dev/null and b/frontend/public/images/logo.png differ
diff --git a/frontend/public/images/logo.svg b/frontend/public/images/logo.svg
new file mode 100644
index 0000000000..49a19b755d
--- /dev/null
+++ b/frontend/public/images/logo.svg
@@ -0,0 +1,55 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/public/images/logo128.png b/frontend/public/images/logo128.png
index 467bde320b..4f8fe42bdc 100644
Binary files a/frontend/public/images/logo128.png and b/frontend/public/images/logo128.png differ
diff --git a/frontend/public/images/logo64.png b/frontend/public/images/logo64.png
index 891190143f..03e7685768 100644
Binary files a/frontend/public/images/logo64.png and b/frontend/public/images/logo64.png differ
diff --git a/frontend/public/images/logo_minimal_b.png b/frontend/public/images/logo_minimal_b.png
new file mode 100644
index 0000000000..585df9c81c
Binary files /dev/null and b/frontend/public/images/logo_minimal_b.png differ
diff --git a/frontend/public/images/logo_minimal_b.svg b/frontend/public/images/logo_minimal_b.svg
new file mode 100644
index 0000000000..2651b2727d
--- /dev/null
+++ b/frontend/public/images/logo_minimal_b.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/public/images/logo_minimal_b128.png b/frontend/public/images/logo_minimal_b128.png
new file mode 100644
index 0000000000..8718706cba
Binary files /dev/null and b/frontend/public/images/logo_minimal_b128.png differ
diff --git a/frontend/public/images/logo_minimal_b64.png b/frontend/public/images/logo_minimal_b64.png
new file mode 100644
index 0000000000..16c976043a
Binary files /dev/null and b/frontend/public/images/logo_minimal_b64.png differ
diff --git a/frontend/public/images/logo_minimal_w.png b/frontend/public/images/logo_minimal_w.png
new file mode 100644
index 0000000000..db081b03cc
Binary files /dev/null and b/frontend/public/images/logo_minimal_w.png differ
diff --git a/frontend/public/images/logo_minimal_w.svg b/frontend/public/images/logo_minimal_w.svg
new file mode 100644
index 0000000000..99436cb939
--- /dev/null
+++ b/frontend/public/images/logo_minimal_w.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/public/images/logo_minimal_w128.png b/frontend/public/images/logo_minimal_w128.png
new file mode 100644
index 0000000000..951f259f23
Binary files /dev/null and b/frontend/public/images/logo_minimal_w128.png differ
diff --git a/frontend/public/images/logo_minimal_w64.png b/frontend/public/images/logo_minimal_w64.png
new file mode 100644
index 0000000000..675b926be6
Binary files /dev/null and b/frontend/public/images/logo_minimal_w64.png differ
diff --git a/frontend/public/images/logo_no_orb.png b/frontend/public/images/logo_no_orb.png
new file mode 100644
index 0000000000..c89c016e68
Binary files /dev/null and b/frontend/public/images/logo_no_orb.png differ
diff --git a/frontend/public/images/logo_no_orb.svg b/frontend/public/images/logo_no_orb.svg
new file mode 100644
index 0000000000..01ae43816d
--- /dev/null
+++ b/frontend/public/images/logo_no_orb.svg
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/public/images/logo_no_orb128.png b/frontend/public/images/logo_no_orb128.png
new file mode 100644
index 0000000000..e0cfc893dd
Binary files /dev/null and b/frontend/public/images/logo_no_orb128.png differ
diff --git a/frontend/public/images/logo_no_orb64.png b/frontend/public/images/logo_no_orb64.png
new file mode 100644
index 0000000000..46804394d3
Binary files /dev/null and b/frontend/public/images/logo_no_orb64.png differ
diff --git a/frontend/public/images/maskable-icon-512x512.png b/frontend/public/images/maskable-icon-512x512.png
index e813d5ddf7..a6f20b650d 100644
Binary files a/frontend/public/images/maskable-icon-512x512.png and b/frontend/public/images/maskable-icon-512x512.png differ
diff --git a/frontend/public/images/mstile-150x150.png b/frontend/public/images/mstile-150x150.png
index 362cfe6c53..1cbdf406eb 100644
Binary files a/frontend/public/images/mstile-150x150.png and b/frontend/public/images/mstile-150x150.png differ
diff --git a/frontend/public/images/pwa-192x192.png b/frontend/public/images/pwa-192x192.png
index a93a96a653..dc45d26c54 100644
Binary files a/frontend/public/images/pwa-192x192.png and b/frontend/public/images/pwa-192x192.png differ
diff --git a/frontend/public/images/pwa-512x512.png b/frontend/public/images/pwa-512x512.png
index 298f27dbae..b05f07065d 100644
Binary files a/frontend/public/images/pwa-512x512.png and b/frontend/public/images/pwa-512x512.png differ
diff --git a/frontend/public/images/pwa-64x64.png b/frontend/public/images/pwa-64x64.png
index 41487e49c5..fa0f87cbd0 100644
Binary files a/frontend/public/images/pwa-64x64.png and b/frontend/public/images/pwa-64x64.png differ
diff --git a/frontend/src/App/Header.module.scss b/frontend/src/App/Header.module.scss
index 8f22398097..47e5096592 100644
--- a/frontend/src/App/Header.module.scss
+++ b/frontend/src/App/Header.module.scss
@@ -1,9 +1,52 @@
.header {
- @include mantine.light {
- color: var(--mantine-color-gray-0);
+ box-shadow: none;
+}
+
+.headerInner {
+ background: var(--bz-surface-base);
+ border: 1px solid var(--bz-border-card);
+ border-radius: var(--bz-radius-xl);
+ padding: 10px 16px;
+ margin-right: 8px;
+ display: flex;
+ align-items: center;
+
+ color: var(--bz-text-primary);
+
+ // Ghost-style action buttons (theme toggle, notifications, system gear)
+ :global(.mantine-ActionIcon-root) {
+ width: 32px;
+ height: 32px;
+ min-width: 32px;
+ min-height: 32px;
+ background: var(--bz-hover-bg);
+ border: 1px solid var(--bz-border-interactive);
+ border-radius: var(--bz-radius-md);
+ color: var(--bz-text-tertiary);
+ font-size: 12px;
+ font-weight: 500;
+ &:hover {
+ background: var(--bz-hover-bg-emphasis);
+ }
}
- @include mantine.dark {
- color: var(--mantine-color-dark-0);
+ // Search input
+ :global(.mantine-Select-input) {
+ background: var(--bz-hover-bg);
+ border: 1px solid var(--bz-border-interactive);
+ border-radius: var(--bz-radius-md);
+ transition: background var(--bz-duration-normal) var(--bz-ease-standard);
+
+ &::placeholder {
+ color: var(--bz-text-tertiary);
+ }
+
+ &:hover {
+ background: var(--bz-hover-bg-emphasis);
+ }
+
+ &:focus {
+ background: var(--bz-hover-bg-emphasis);
+ }
}
}
diff --git a/frontend/src/App/Header.tsx b/frontend/src/App/Header.tsx
index 11cb58e8a5..89f56d3858 100644
--- a/frontend/src/App/Header.tsx
+++ b/frontend/src/App/Header.tsx
@@ -3,18 +3,22 @@ import {
Anchor,
AppShell,
Avatar,
- Badge,
Burger,
Divider,
Group,
Menu,
+ Text,
+ useComputedColorScheme,
+ useMantineColorScheme,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
-import { faBell } from "@fortawesome/free-regular-svg-icons/faBell";
+import { faListCheck } from "@fortawesome/free-solid-svg-icons";
import {
faArrowRotateLeft,
faGear,
+ faMoon,
faPowerOff,
+ faSun,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useSystem, useSystemJobs, useSystemSettings } from "@/apis/hooks";
@@ -38,6 +42,9 @@ const AppHeader: FunctionComponent = () => {
const goHome = useGotoHomepage();
+ const { toggleColorScheme } = useMantineColorScheme();
+ const dark = useComputedColorScheme("light") === "dark";
+
const [
jobsManagerOpened,
{ open: openJobsManager, close: closeJobsManager },
@@ -46,70 +53,104 @@ const AppHeader: FunctionComponent = () => {
const { data: jobs } = useSystemJobs();
return (
-
-
-
- show(!showed)}
- size="sm"
- hiddenFrom="sm"
- >
-
-
-
-
- Bazarr
-
-
-
-
- job.status === "running").length,
- )}
- onClick={openJobsManager}
- >
-
-
-
-
-
- }
- onClick={() => restart()}
- >
- Restart
-
- }
- onClick={() => shutdown()}
- >
- Shutdown
-
-
- logout()}>
- Logout
-
-
-
+
+
+
+
+ show(!showed)}
+ size="sm"
+ hiddenFrom="sm"
+ >
+
+
+
+
+ Bazarr
+
+ +
+
+
+
+
+
+
+
+
+
+ toggleColorScheme()}
+ icon={dark ? faSun : faMoon}
+ size="sm"
+ >
+ job.status === "running").length,
+ )}
+ onClick={openJobsManager}
+ >
+
+
+
+
+
+ }
+ onClick={() => restart()}
+ >
+ Restart
+
+ }
+ onClick={() => shutdown()}
+ >
+ Shutdown
+
+
+ logout()}>
+ Logout
+
+
+
+
-
+
{
- const [selection, select] = useState(null);
+// Section grouping configuration.
+// Routes are matched by their path property.
+const sectionGroups = [
+ { label: "Media", paths: ["series", "movies"] },
+ { label: "Management", paths: ["history", "wanted", "blacklist"] },
+ { label: "System", paths: ["settings", "system"] },
+];
+
+function groupRoutes(routes: CustomRouteObject[]) {
+ // Filter to visible nav items (have a path, not hidden, not index-only)
+ const navItems = routes.filter(
+ (r) => r.path !== undefined && !r.hidden && !r.path.includes(":") && r.name,
+ );
- const { toggleColorScheme } = useMantineColorScheme();
- const computedColorScheme = useComputedColorScheme("light");
+ const groups: { label: string; items: CustomRouteObject[] }[] = [];
- const dark = computedColorScheme === "dark";
+ for (const section of sectionGroups) {
+ const items = section.paths
+ .map((p) => navItems.find((r) => r.path === p))
+ .filter((r): r is CustomRouteObject => r !== undefined);
+
+ if (items.length > 0) {
+ groups.push({ label: section.label, items });
+ }
+ }
+
+ // Catch any remaining items not in a defined group
+ const groupedPaths = new Set(sectionGroups.flatMap((s) => s.paths));
+ const ungrouped = navItems.filter((r) => !groupedPaths.has(r.path ?? ""));
+ if (ungrouped.length > 0) {
+ groups.push({ label: "Other", items: ungrouped });
+ }
+
+ return groups;
+}
+
+const AppNavbar: FunctionComponent = () => {
+ const [selection, select] = useState(null);
const routes = useRouteItems();
@@ -111,41 +125,36 @@ const AppNavbar: FunctionComponent = () => {
select(null);
}, [pathname]);
+ // The top-level route (path "/") contains the nav items as children.
+ // useRouteItems returns the full routes array, and the nameless "/" route
+ // renders its children directly. We need to find the app route's children.
+ const navRoutes = useMemo(() => {
+ const appRoute = routes.find((r) => r.path === "/");
+ return appRoute?.children ?? routes;
+ }, [routes]);
+
+ const groups = useMemo(() => groupRoutes(navRoutes), [navRoutes]);
+
return (
-
-
-
+
+
+
- {routes.map((route, idx) => (
-
+ {groups.map((group) => (
+
+
{group.label}
+ {group.items.map((route, idx) => (
+
+ ))}
+
))}
-
-
-
-
- toggleColorScheme()}
- icon={dark ? faSun : faMoon}
- >
-
-
-
-
-
-
+
+
);
};
@@ -177,7 +186,7 @@ const RouteItem: FunctionComponent<{
parent={link}
key={BuildKey(link, "nav", idx)}
route={child}
- >
+ />
))}
);
@@ -186,7 +195,6 @@ const RouteItem: FunctionComponent<{
return (
+ />
{elements}
@@ -219,12 +227,7 @@ const RouteItem: FunctionComponent<{
}
} else {
return (
-
+
);
}
};
@@ -234,7 +237,6 @@ interface NavbarItemProps {
link: string;
icon?: IconDefinition;
badge?: number | string;
- primary?: boolean;
onClick?: (event: React.MouseEvent) => void;
}
@@ -244,7 +246,6 @@ const NavbarItem: FunctionComponent = ({
name,
badge,
onClick,
- primary = false,
}) => {
const { show } = useNavbar();
@@ -278,24 +279,11 @@ const NavbarItem: FunctionComponent = ({
)
}
>
-
- {icon && (
-
- )}
+
+ {icon && }
{name}
{!shouldHideBadge && (
-
+
{badge}
)}
diff --git a/frontend/src/App/NotificationDrawer.tsx b/frontend/src/App/NotificationDrawer.tsx
index 170114609f..b4917afb40 100644
--- a/frontend/src/App/NotificationDrawer.tsx
+++ b/frontend/src/App/NotificationDrawer.tsx
@@ -139,7 +139,7 @@ const NotificationDrawer: FunctionComponent = ({
)}
{!jobsLoading && jobsError && (
-
+
Failed to load jobs.
@@ -227,7 +227,7 @@ const NotificationDrawer: FunctionComponent = ({
)}
-
+
{grouped[status as string].length} job
{grouped[status as string].length > 1 ? "s" : ""}
@@ -254,7 +254,7 @@ const NotificationDrawer: FunctionComponent = ({
= ({
position="right"
>
= ({
job.progress_max) *
100
: 0,
- color: "brand",
+ color:
+ status === "completed"
+ ? "green"
+ : status === "failed"
+ ? "red"
+ : "brand",
},
]}
label={
{status === "completed" &&
job.progress_max == 0 &&
@@ -467,7 +477,10 @@ const NotificationDrawer: FunctionComponent = ({
{job?.progress_message && (
-
+
{job.progress_message}
)}
@@ -481,14 +494,14 @@ const NotificationDrawer: FunctionComponent = ({
));
})()
) : (
-
+
No jobs to display
)}
>
) : (
-
-
+
+
Jobs
{
setOnline(detail.online);
});
+ const [upgradeModalOpen, setUpgradeModalOpen] = useState(false);
+ const [upgrading, setUpgrading] = useState(false);
+
useEffect(() => {
if (Environment.hasUpdate) {
showNotification(
@@ -46,6 +50,41 @@ const App: FunctionComponent = () => {
}
}, []);
+ useEffect(() => {
+ const token = sessionStorage.getItem("password_upgrade_token");
+ if (token) {
+ setUpgradeModalOpen(true);
+ }
+ }, []);
+
+ const handleUpgradeAccept = useCallback(async () => {
+ const token = sessionStorage.getItem("password_upgrade_token");
+ if (!token) return;
+ setUpgrading(true);
+ try {
+ await api.system.upgradePasswordHash(token);
+ showNotification(
+ notification.info(
+ "Password upgraded",
+ "Your password hash has been upgraded to PBKDF2-SHA256",
+ ),
+ );
+ } catch {
+ showNotification(
+ notification.warn("Upgrade failed", "Could not upgrade password hash"),
+ );
+ } finally {
+ sessionStorage.removeItem("password_upgrade_token");
+ setUpgradeModalOpen(false);
+ setUpgrading(false);
+ }
+ }, []);
+
+ const handleUpgradeDecline = useCallback(() => {
+ sessionStorage.removeItem("password_upgrade_token");
+ setUpgradeModalOpen(false);
+ }, []);
+
if (criticalError !== null) {
return ;
}
@@ -69,6 +108,31 @@ const App: FunctionComponent = () => {
+
+
+
+ Your password is currently stored using a weak MD5 hash. Would
+ you like to upgrade to PBKDF2-SHA256 for better security?
+
+
+ Note: After upgrading, reverting to upstream Bazarr will require
+ resetting your password via the config file.
+
+
+
+ Not now
+
+
+ Upgrade
+
+
+
+
diff --git a/frontend/src/Router/index.tsx b/frontend/src/Router/index.tsx
index 7ccff2ceb9..0bdf5b37ff 100644
--- a/frontend/src/Router/index.tsx
+++ b/frontend/src/Router/index.tsx
@@ -28,9 +28,7 @@ import MoviesHistoryView from "@/pages/History/Movies";
import SeriesHistoryView from "@/pages/History/Series";
import MovieView from "@/pages/Movies";
import MovieDetailView from "@/pages/Movies/Details";
-import MovieMassEditor from "@/pages/Movies/Editor";
import SeriesView from "@/pages/Series";
-import SeriesMassEditor from "@/pages/Series/Editor";
import SettingsGeneralView from "@/pages/Settings/General";
import SettingsLanguagesView from "@/pages/Settings/Languages";
import SettingsNotificationsView from "@/pages/Settings/Notifications";
@@ -40,6 +38,7 @@ import SettingsRadarrView from "@/pages/Settings/Radarr";
import SettingsSchedulerView from "@/pages/Settings/Scheduler";
import SettingsSonarrView from "@/pages/Settings/Sonarr";
import SettingsSubtitlesView from "@/pages/Settings/Subtitles";
+import SettingsTranslatorView from "@/pages/Settings/Translator";
import SettingsUIView from "@/pages/Settings/UI";
import SystemAnnouncementsView from "@/pages/System/Announcements";
import SystemBackupsView from "@/pages/System/Backups";
@@ -58,6 +57,7 @@ const HistoryStats = lazy(
() => import("@/pages/History/Statistics/HistoryStats"),
);
const SystemStatusView = lazy(() => import("@/pages/System/Status"));
+const SubtitleEditor = lazy(() => import("@/pages/SubtitleEditor"));
function useRoutes(): CustomRouteObject[] {
const { data } = useBadges();
@@ -84,11 +84,6 @@ function useRoutes(): CustomRouteObject[] {
index: true,
element: ,
},
- {
- path: "edit",
- hidden: true,
- element: ,
- },
{
path: ":id",
element: ,
@@ -106,11 +101,6 @@ function useRoutes(): CustomRouteObject[] {
index: true,
element: ,
},
- {
- path: "edit",
- hidden: true,
- element: ,
- },
{
path: ":id",
element: ,
@@ -228,6 +218,11 @@ function useRoutes(): CustomRouteObject[] {
name: "Plex",
element: ,
},
+ {
+ path: "translator",
+ name: "AI Translator",
+ element: ,
+ },
{
path: "notifications",
name: "Notifications",
@@ -296,6 +291,15 @@ function useRoutes(): CustomRouteObject[] {
},
],
},
+ {
+ path: "subtitles/preview/:mediaType/:mediaId/:language",
+ hidden: true,
+ element: (
+
+
+
+ ),
+ },
{
path: "*",
hidden: true,
diff --git a/frontend/src/apis/hooks/plex.ts b/frontend/src/apis/hooks/plex.ts
index 497d358ea1..873aab6a25 100644
--- a/frontend/src/apis/hooks/plex.ts
+++ b/frontend/src/apis/hooks/plex.ts
@@ -70,6 +70,7 @@ export const usePlexPinMutation = () => {
export const usePlexPinCheckQuery = (
pinId: string | null,
+ state: string | null,
enabled: boolean,
refetchInterval: number | false,
) => {
@@ -77,7 +78,7 @@ export const usePlexPinCheckQuery = (
queryKey: [QueryKeys.Plex, "pinCheck", pinId],
queryFn: () => {
if (!pinId) throw new Error("Pin ID is required");
- return api.plex.checkPin(pinId);
+ return api.plex.checkPin(pinId, state ?? undefined);
},
enabled: enabled && !!pinId,
retry: false,
diff --git a/frontend/src/apis/hooks/subtitles.ts b/frontend/src/apis/hooks/subtitles.ts
index 50f22c9f7b..89910c7ee4 100644
--- a/frontend/src/apis/hooks/subtitles.ts
+++ b/frontend/src/apis/hooks/subtitles.ts
@@ -1,7 +1,7 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { QueryKeys } from "@/apis/queries/keys";
import api from "@/apis/raw";
-import { BatchTranslateItem } from "@/apis/raw/subtitles";
+import { BatchAction, BatchItem, BatchOptions } from "@/apis/raw/subtitles";
export function useSubtitleAction() {
const client = useQueryClient();
@@ -147,6 +147,14 @@ export function useSubtitleInfos(names: string[]) {
});
}
+export function useSubtitleContents(subtitlePath: string) {
+ return useQuery({
+ queryKey: [QueryKeys.Subtitles, subtitlePath],
+ queryFn: () => api.subtitles.contents(subtitlePath),
+ staleTime: Infinity,
+ });
+}
+
export function useRefTracksByEpisodeId(
subtitlesPath: string,
sonarrEpisodeId: number,
@@ -183,24 +191,54 @@ export function useRefTracksByMovieId(
});
}
-export function useBatchTranslate() {
+export function useUpgradableItems() {
+ return useQuery({
+ queryKey: [QueryKeys.Subtitles, "upgradable"],
+ queryFn: () => api.subtitles.upgradable(),
+ refetchInterval: 60000,
+ });
+}
+
+export function useBatchAction() {
const client = useQueryClient();
return useMutation({
- mutationKey: [QueryKeys.Subtitles, "batch-translate"],
- mutationFn: (items: BatchTranslateItem[]) =>
- api.subtitles.batchTranslate(items),
+ mutationKey: [QueryKeys.Subtitles, "batch"],
+ mutationFn: (params: {
+ items: BatchItem[];
+ action: BatchAction;
+ options?: BatchOptions;
+ }) => api.subtitles.batch(params.items, params.action, params.options),
onSuccess: () => {
- // Invalidate wanted queries to refresh the lists
void client.invalidateQueries({
- queryKey: [QueryKeys.Series, QueryKeys.Wanted],
+ queryKey: [QueryKeys.Series],
+ });
+ void client.invalidateQueries({
+ queryKey: [QueryKeys.Movies],
});
void client.invalidateQueries({
- queryKey: [QueryKeys.Movies, QueryKeys.Wanted],
+ queryKey: [QueryKeys.History],
});
- // Also invalidate translator jobs to show new jobs
void client.invalidateQueries({
queryKey: [QueryKeys.Translator],
});
},
});
}
+
+export function useSubtitleContent(
+ mediaType: string | undefined,
+ mediaId: number | undefined,
+ language: string | undefined,
+) {
+ return useQuery({
+ queryKey: [QueryKeys.Subtitles, "content", mediaType, mediaId, language],
+ queryFn: () => {
+ if (!mediaType || mediaId === undefined || !language) {
+ throw new Error("Missing parameters");
+ }
+ return api.subtitles.getContent(mediaType, mediaId, language);
+ },
+ enabled: !!mediaType && mediaId !== undefined && !!language,
+ staleTime: 5 * 60 * 1000, // 5 min, not Infinity - subtitle files can change
+ });
+}
diff --git a/frontend/src/apis/hooks/system.ts b/frontend/src/apis/hooks/system.ts
index 3229c141ff..49fb92aad2 100644
--- a/frontend/src/apis/hooks/system.ts
+++ b/frontend/src/apis/hooks/system.ts
@@ -274,7 +274,16 @@ export function useSystem() {
mutationFn: (param: { username: string; password: string }) =>
api.system.login(param.username, param.password),
- onSuccess: () => {
+ onSuccess: (data) => {
+ if (
+ data &&
+ typeof data === "object" &&
+ "upgrade_token" in data &&
+ data.upgrade_token
+ ) {
+ // Store opaque token (not password) for upgrade prompt
+ sessionStorage.setItem("password_upgrade_token", data.upgrade_token);
+ }
// TODO: Hard-coded value
window.location.replace(Environment.baseUrl);
},
diff --git a/frontend/src/apis/hooks/translator.ts b/frontend/src/apis/hooks/translator.ts
index cf6879458c..e8415c2abf 100644
--- a/frontend/src/apis/hooks/translator.ts
+++ b/frontend/src/apis/hooks/translator.ts
@@ -4,7 +4,13 @@ import client from "@/apis/raw/client";
export interface TranslatorJob {
jobId: string;
- status: "queued" | "processing" | "completed" | "failed" | "cancelled";
+ status:
+ | "queued"
+ | "processing"
+ | "completed"
+ | "partial"
+ | "failed"
+ | "cancelled";
progress: number;
message?: string;
createdAt: string;
@@ -14,6 +20,21 @@ export interface TranslatorJob {
sourceLanguage?: string;
targetLanguage?: string;
filename?: string;
+ title?: string;
+ mediaType?: string;
+ model?: string;
+ jobName?: string;
+ totalLines?: number;
+ completedLines?: number;
+ totalBatches?: number;
+ completedBatches?: number;
+ tokensUsed?: number;
+ totalCost?: number;
+ elapsedSeconds?: number;
+ result?: {
+ model_used?: string;
+ tokens_used?: number;
+ };
}
export interface TranslatorStatus {
@@ -32,6 +53,10 @@ export interface TranslatorStatus {
failed: number;
total: number;
};
+ bazarr_queue?: {
+ pending: number;
+ running: number;
+ };
}
export interface TranslatorJobsResponse {
@@ -152,3 +177,36 @@ export function useTranslatorModels(enabled = true) {
throwOnError: false,
});
}
+
+export interface TranslatorTestResponse {
+ encryption?: {
+ status: string;
+ message: string;
+ } | null;
+ apiKey?: {
+ status: string;
+ label: string;
+ limitRemaining: number;
+ usage: number;
+ isFreeTier: boolean;
+ } | null;
+ error?: string;
+}
+
+export interface TranslatorTestParams {
+ serviceUrl?: string;
+ apiKey?: string;
+ encryptionKey?: string;
+}
+
+export function useTestTranslator() {
+ return useMutation({
+ mutationFn: async (params?: TranslatorTestParams) => {
+ const response = await client.axios.post(
+ "/translator/test",
+ params,
+ );
+ return response.data;
+ },
+ });
+}
diff --git a/frontend/src/apis/raw/base.ts b/frontend/src/apis/raw/base.ts
index 16f7bd5f32..07e660675b 100644
--- a/frontend/src/apis/raw/base.ts
+++ b/frontend/src/apis/raw/base.ts
@@ -49,7 +49,7 @@ class BaseApi {
protected postRaw(
path: string,
- data?: any,
+ data?: unknown,
params?: LooseObject,
): Promise> {
return client.axios.post(this.prefix + path, data, { params });
diff --git a/frontend/src/apis/raw/client.ts b/frontend/src/apis/raw/client.ts
index f3c2f53a37..ae1d48cca1 100644
--- a/frontend/src/apis/raw/client.ts
+++ b/frontend/src/apis/raw/client.ts
@@ -9,9 +9,12 @@ import { setAuthenticated } from "@/utilities/event";
function GetErrorMessage(data: unknown, defaultMsg = "Unknown error"): string {
if (typeof data === "string") {
return data;
- } else {
- return defaultMsg;
+ } else if (typeof data === "object" && data !== null) {
+ const obj = data as Record;
+ if (typeof obj.error === "string") return obj.error;
+ if (typeof obj.message === "string") return obj.message;
}
+ return defaultMsg;
}
class BazarrClient {
diff --git a/frontend/src/apis/raw/plex.ts b/frontend/src/apis/raw/plex.ts
index 4318cf5972..41950cea25 100644
--- a/frontend/src/apis/raw/plex.ts
+++ b/frontend/src/apis/raw/plex.ts
@@ -11,10 +11,10 @@ class NewPlexApi extends BaseApi {
return response.data;
}
- async checkPin(pinId: string) {
- // TODO: Can this be replaced with params instead of passing a variable in the path?
+ async checkPin(pinId: string, state?: string) {
const response = await this.get>(
`/oauth/pin/${pinId}/check`,
+ state ? { state } : undefined,
);
return response.data;
diff --git a/frontend/src/apis/raw/subtitles.ts b/frontend/src/apis/raw/subtitles.ts
index a126ecc8a7..1594c43ef1 100644
--- a/frontend/src/apis/raw/subtitles.ts
+++ b/frontend/src/apis/raw/subtitles.ts
@@ -1,18 +1,70 @@
import BaseApi from "./base";
+import client from "./client";
-export interface BatchTranslateItem {
- type: "episode" | "movie";
+export interface SubtitleContentResponse {
+ content: string;
+ encoding: string;
+ format: string;
+ language: string;
+ size: number;
+ lastModified: number;
+ mediaTitle?: string;
+ mediaId?: number;
+ episodeTitle?: string;
+}
+
+export type BatchAction =
+ | "sync"
+ | "translate"
+ | "OCR_fixes"
+ | "common"
+ | "remove_HI"
+ | "remove_tags"
+ | "fix_uppercase"
+ | "reverse_rtl"
+ | "scan-disk"
+ | "search-missing"
+ | "upgrade";
+
+export interface BatchItem {
+ type: "episode" | "movie" | "series";
sonarrSeriesId?: number;
sonarrEpisodeId?: number;
radarrId?: number;
- sourceLanguage: string;
- targetLanguage: string;
- subtitlePath?: string;
- forced?: boolean;
- hi?: boolean;
}
-export interface BatchTranslateResponse {
+export interface BatchOptions {
+ maxOffsetSeconds?: number;
+ noFixFramerate?: boolean;
+ gss?: boolean;
+ forceResync?: boolean;
+ fromLang?: string;
+ toLang?: string;
+}
+
+/* eslint-disable camelcase -- backend API contract */
+interface BatchPayload {
+ max_offset_seconds?: number;
+ no_fix_framerate?: boolean;
+ gss?: boolean;
+ force_resync?: boolean;
+ from_lang?: string;
+ to_lang?: string;
+}
+
+function toBatchPayload(options: BatchOptions): BatchPayload {
+ return {
+ max_offset_seconds: options.maxOffsetSeconds,
+ no_fix_framerate: options.noFixFramerate,
+ gss: options.gss,
+ force_resync: options.forceResync,
+ from_lang: options.fromLang,
+ to_lang: options.toLang,
+ };
+}
+/* eslint-enable camelcase */
+
+export interface BatchResponse {
queued: number;
skipped: number;
errors: string[];
@@ -56,12 +108,39 @@ class SubtitlesApi extends BaseApi {
await this.patch("", form, { action });
}
- async batchTranslate(
- items: BatchTranslateItem[],
- ): Promise {
- const response = await this.postRaw(
- "/translate/batch",
- { items },
+ async batch(
+ items: BatchItem[],
+ action: BatchAction,
+ options?: BatchOptions,
+ ): Promise {
+ const response = await this.postRaw("/batch", {
+ items,
+ action,
+ options: options ? toBatchPayload(options) : undefined,
+ });
+ return response.data;
+ }
+
+ async upgradable(): Promise<{ movies: number[]; series: number[] }> {
+ const response = await this.get<{ movies: number[]; series: number[] }>(
+ "/upgradable",
+ );
+ return response;
+ }
+
+ async getContent(mediaType: string, mediaId: number, language: string) {
+ const base = mediaType === "episode" ? "episodes" : "movies";
+ const url = `/${base}/${mediaId}/subtitles/${encodeURIComponent(language)}/content`;
+ const response = await client.axios.get(url);
+ return response.data;
+ }
+
+ async contents(subtitlePath: string) {
+ const response = await this.get>(
+ "/contents",
+ {
+ subtitlePath,
+ },
);
return response.data;
}
diff --git a/frontend/src/apis/raw/system.ts b/frontend/src/apis/raw/system.ts
index a53eb2edeb..24ba7f49d3 100644
--- a/frontend/src/apis/raw/system.ts
+++ b/frontend/src/apis/raw/system.ts
@@ -10,7 +10,19 @@ class SystemApi extends BaseApi {
}
async login(username: string, password: string) {
- await this.post("/account", { username, password }, { action: "login" });
+ const response = await this.post<{
+ upgrade_hash?: boolean;
+ upgrade_token?: string;
+ }>("/account", { username, password }, { action: "login" });
+ return response.data;
+ }
+
+ async upgradePasswordHash(upgradeToken: string) {
+ await this.post(
+ "/account",
+ { password: upgradeToken },
+ { action: "upgrade_hash" },
+ );
}
async logout() {
diff --git a/frontend/src/assets/_bazarr.scss b/frontend/src/assets/_bazarr.scss
index 55587c78d7..a6e8ca052c 100644
--- a/frontend/src/assets/_bazarr.scss
+++ b/frontend/src/assets/_bazarr.scss
@@ -1,53 +1,17 @@
@use "mantine" as *;
-$color-brand-0: #f8f0fc;
-$color-brand-1: #f3d9fa;
-$color-brand-2: #eebefa;
-$color-brand-3: #e599f7;
-$color-brand-4: #da77f2;
-$color-brand-5: #cc5de8;
-$color-brand-6: #be4bdb;
-$color-brand-7: #ae3ec9;
-$color-brand-8: #9c36b5;
-$color-brand-9: #862e9c;
-
-// Based on Mantine Cyan
-$color-highlight-0: #e3fafc;
-$color-highlight-1: #c5f6fa;
-$color-highlight-2: #99e9f2;
-$color-highlight-3: #66d9e8;
-$color-highlight-4: #3bc9db;
-$color-highlight-5: #22b8cf;
-$color-highlight-6: #15aabf;
-$color-highlight-7: #1098ad;
-$color-highlight-8: #0c8599;
-$color-highlight-9: #0b7285;
-
-// Based on Mantine Yellow
-$color-warning-0: #fff9db;
-$color-warning-1: #fff3bf;
-$color-warning-2: #ffec99;
-$color-warning-3: #ffe066;
-$color-warning-4: #ffd43b;
-$color-warning-5: #fcc419;
-$color-warning-6: #fab005;
-$color-warning-7: #f59f00;
-$color-warning-8: #f08c00;
-$color-warning-9: #e67700;
-
-// Based on Mantine Gray
-$color-disabled-0: #f8f9fa;
-$color-disabled-1: #f1f3f5;
-$color-disabled-2: #e9ecef;
-$color-disabled-3: #dee2e6;
-$color-disabled-4: #ced4da;
-$color-disabled-5: #adb5bd;
-$color-disabled-6: #868e96;
-$color-disabled-7: #495057;
-$color-disabled-8: #343a40;
-$color-disabled-9: #212529;
-
-$header-height: 64px;
+$color-brand-0: #fff8e1;
+$color-brand-1: #ffecb3;
+$color-brand-2: #ffe082;
+$color-brand-3: #ffd54f;
+$color-brand-4: #ffca28;
+$color-brand-5: #e68a00;
+$color-brand-6: #b36b00;
+$color-brand-7: #995c00;
+$color-brand-8: #7a4900;
+$color-brand-9: #5c3700;
+
+$header-height: 76px;
:global {
.table-long-break {
@@ -82,6 +46,338 @@ $header-height: 64px;
:root {
@include dark {
- --mantine-color-body: var(--mantine-color-dark-8);
+ --mantine-color-body: var(--mantine-color-dark-9);
+ }
+}
+
+// Grain texture overlay (z-index above content, pointer-events:none)
+body::before {
+ content: "";
+ position: fixed;
+ inset: 0;
+ z-index: 10000;
+ pointer-events: none;
+ opacity: 0.07;
+ background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.7' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
+ background-repeat: repeat;
+ background-size: 256px 256px;
+
+ @include light {
+ opacity: 0.035;
+ mix-blend-mode: multiply;
+ }
+}
+
+// Ambient atmospheric glow (z-index above content backgrounds, below grain)
+body::after {
+ content: "";
+ position: fixed;
+ inset: 0;
+ z-index: 9999;
+ pointer-events: none;
+ background:
+ radial-gradient(
+ ellipse at 15% 10%,
+ rgba(230, 138, 0, 0.12) 0%,
+ transparent 50%
+ ),
+ radial-gradient(
+ ellipse at 85% 90%,
+ rgba(26, 138, 138, 0.08) 0%,
+ transparent 50%
+ ),
+ radial-gradient(
+ ellipse at 50% 50%,
+ rgba(140, 80, 200, 0.05) 0%,
+ transparent 60%
+ );
+
+ @include light {
+ display: none;
+ }
+}
+
+#root {
+ position: relative;
+ z-index: 1;
+}
+
+/* Shared tokens (scheme-independent) */
+:root {
+ /* Border radius scale */
+ --bz-radius-xs: 6px;
+ --bz-radius-sm: 8px;
+ --bz-radius-md: 10px;
+ --bz-radius-lg: 14px;
+ --bz-radius-xl: 20px;
+
+ /* Animation tokens */
+ --bz-ease-standard: cubic-bezier(0.2, 0, 0, 1);
+ --bz-ease-enter: cubic-bezier(0, 0, 0.2, 1);
+ --bz-ease-exit: cubic-bezier(0.4, 0, 1, 1);
+ --bz-duration-fast: 120ms;
+ --bz-duration-normal: 150ms;
+ --bz-duration-slow: 250ms;
+ --bz-duration-card: 300ms;
+ --bz-duration-page: 200ms;
+
+ /* Stagger tokens */
+ --bz-stagger-delay: 40ms;
+}
+
+:root[data-mantine-color-scheme="dark"] {
+ --bz-surface-ground: #0c0b1a;
+ --bz-surface-base: #121125;
+ --bz-surface-raised: #1a1a2e;
+ --bz-surface-card: #22223a;
+ --bz-surface-card-hover: #2a2a45;
+ --bz-surface-overlay: #2a2a45;
+ --bz-stat-processing: #ffca28;
+ --bz-stat-queued: #4dabf7;
+ --bz-stat-completed: #69db7c;
+ --bz-stat-failed: #ff6b6b;
+ --bz-shadow-float: 0 4px 12px rgba(0, 0, 0, 0.5);
+
+ /* Text hierarchy */
+ --bz-text-primary: #e8e8f0;
+ --bz-text-secondary: #b8b8cc;
+ --bz-text-tertiary: #8e8fa3;
+ --bz-text-disabled: #6c6d85;
+
+ /* Border opacity scale */
+ --bz-border-divider: rgba(255, 255, 255, 0.04);
+ --bz-border-card: rgba(255, 255, 255, 0.05);
+ --bz-border-interactive: rgba(255, 255, 255, 0.06);
+ --bz-border-hover: rgba(255, 255, 255, 0.07);
+
+ /* Hover tokens */
+ --bz-hover-bg: rgba(255, 255, 255, 0.04);
+ --bz-hover-bg-emphasis: rgba(255, 255, 255, 0.07);
+}
+
+:root[data-mantine-color-scheme="light"] {
+ --bz-text-primary: #12121f;
+ --bz-text-secondary: #343a40;
+ --bz-text-tertiary: #656d76;
+ --bz-text-disabled: #909aa5;
+
+ --bz-border-divider: rgba(0, 0, 0, 0.06);
+ --bz-border-card: rgba(0, 0, 0, 0.08);
+ --bz-border-interactive: rgba(0, 0, 0, 0.1);
+ --bz-border-hover: rgba(0, 0, 0, 0.12);
+
+ --bz-surface-ground: #f8f9fa;
+ --bz-surface-base: #ffffff;
+ --bz-surface-raised: #f1f3f5;
+ --bz-surface-card: #ffffff;
+ --bz-surface-card-hover: #f8f9fa;
+ --bz-surface-overlay: #ffffff;
+ --bz-stat-processing: #e67700;
+ --bz-stat-queued: #1971c2;
+ --bz-stat-completed: #2b8a3e;
+ --bz-stat-failed: #e03131;
+ --bz-shadow-float: 0 4px 12px rgba(0, 0, 0, 0.1);
+
+ --bz-hover-bg: rgba(0, 0, 0, 0.04);
+ --bz-hover-bg-emphasis: rgba(0, 0, 0, 0.07);
+}
+
+// Floaty main content area
+:global {
+ .mantine-AppShell-main {
+ background: var(--bz-surface-base);
+ margin: 0 8px 8px 0;
+ border-radius: var(--bz-radius-xl);
+ border: 1px solid var(--bz-border-card);
+ }
+
+ .mantine-AppShell-navbar {
+ background: transparent;
+ border-right: none;
+ }
+
+ .mantine-AppShell-header {
+ background: transparent !important;
+ border: none !important;
+ padding: 8px !important;
+ }
+
+ .mantine-AppShell-root {
+ background: var(--bz-surface-ground);
+ }
+}
+
+// Global Paper/Card styling
+:global {
+ .mantine-Paper-root {
+ border-radius: var(--bz-radius-lg);
+
+ &[data-with-border] {
+ border-color: var(--bz-border-card);
+ }
+ }
+
+ .mantine-Alert-root {
+ border-radius: var(--bz-radius-lg);
+ }
+
+ .mantine-Card-root {
+ background: var(--bz-surface-card);
+ border-radius: var(--bz-radius-lg);
+ border: 1px solid var(--bz-border-card);
+ box-shadow: none;
+ transition:
+ background var(--bz-duration-normal) var(--bz-ease-standard),
+ border-color var(--bz-duration-normal) var(--bz-ease-standard);
+
+ &:hover {
+ border-color: var(--bz-border-hover);
+ }
+ }
+
+ // Tabs styling
+ .mantine-Tabs-tab {
+ transition:
+ background var(--bz-duration-normal) var(--bz-ease-standard),
+ color var(--bz-duration-normal) var(--bz-ease-standard),
+ border-color var(--bz-duration-normal) var(--bz-ease-standard);
+ }
+
+ // Switch styling
+ .mantine-Switch-track {
+ transition:
+ background-color var(--bz-duration-normal) var(--bz-ease-standard),
+ border-color var(--bz-duration-normal) var(--bz-ease-standard);
+ }
+
+ // Checkbox styling
+ .mantine-Checkbox-input {
+ border-radius: var(--bz-radius-xs);
+ transition:
+ background-color var(--bz-duration-normal) var(--bz-ease-standard),
+ border-color var(--bz-duration-normal) var(--bz-ease-standard);
+ }
+
+ // Anchor/link styling
+ .mantine-Anchor-root {
+ transition: color var(--bz-duration-normal) var(--bz-ease-standard);
+ }
+
+ // Tooltip styling
+ .mantine-Tooltip-tooltip {
+ background: var(--bz-surface-overlay);
+ border: 1px solid var(--bz-border-card);
+ border-radius: var(--bz-radius-sm);
+ color: var(--bz-text-primary);
+ box-shadow: var(--bz-shadow-float);
+ }
+
+ // Popover styling
+ .mantine-Popover-dropdown {
+ background: var(--bz-surface-overlay);
+ border: 1px solid var(--bz-border-card);
+ border-radius: var(--bz-radius-lg);
+ box-shadow: var(--bz-shadow-float);
+ }
+}
+
+// Global dropdown/menu/combobox styling
+:global {
+ .mantine-Menu-dropdown,
+ .mantine-Combobox-dropdown,
+ .mantine-Select-dropdown,
+ .mantine-MultiSelect-dropdown,
+ .mantine-Autocomplete-dropdown {
+ background: var(--bz-surface-overlay);
+ border: 1px solid var(--bz-border-card);
+ border-radius: var(--bz-radius-lg);
+ box-shadow: var(--bz-shadow-float);
+ }
+
+ .mantine-Menu-item {
+ border-radius: var(--bz-radius-sm);
+ color: var(--bz-text-primary);
+ transition: background var(--bz-duration-normal) var(--bz-ease-standard);
+
+ &:hover,
+ &[data-hovered] {
+ background: var(--bz-hover-bg);
+ }
+ }
+
+ .mantine-Combobox-option {
+ border-radius: var(--bz-radius-sm);
+ transition: background var(--bz-duration-normal) var(--bz-ease-standard);
+
+ &:hover,
+ &[data-combobox-selected],
+ &[data-hovered] {
+ background: var(--bz-hover-bg);
+ }
+ }
+
+ .mantine-Menu-label {
+ color: var(--bz-text-disabled);
+ }
+
+ .mantine-Menu-divider {
+ border-color: var(--bz-border-divider);
+ }
+}
+
+// Card reveal entry animation
+@keyframes bz-card-reveal {
+ from {
+ opacity: 0;
+ transform: translateY(10px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+// Apply card reveal to Mantine Cards and bordered Papers
+:global {
+ .mantine-Card-root {
+ animation: bz-card-reveal var(--bz-duration-card) var(--bz-ease-enter) both;
+ }
+
+ // Stagger cards inside Stack/SimpleGrid containers
+ .mantine-Stack-root,
+ .mantine-SimpleGrid-root {
+ > .mantine-Card-root,
+ > .mantine-Paper-root[data-with-border] {
+ animation: bz-card-reveal var(--bz-duration-card) var(--bz-ease-enter)
+ both;
+
+ @for $i from 1 through 6 {
+ &:nth-child(#{$i}) {
+ animation-delay: calc(#{$i - 1} * var(--bz-stagger-delay));
+ }
+ }
+
+ // Items beyond 6th appear without extra stagger
+ &:nth-child(n + 7) {
+ animation-delay: calc(5 * var(--bz-stagger-delay));
+ }
+ }
+ }
+}
+
+// Reduced motion support
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ animation-duration: 0.01ms !important;
+ animation-delay: 0ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.01ms !important;
+ scroll-behavior: auto !important;
+ }
+
+ body::after {
+ display: none !important;
}
}
diff --git a/frontend/src/assets/_variables.module.scss b/frontend/src/assets/_variables.module.scss
index c707980bfe..c3683f5ab6 100644
--- a/frontend/src/assets/_variables.module.scss
+++ b/frontend/src/assets/_variables.module.scss
@@ -1,5 +1,7 @@
$navbar-width: 200;
+$surface-ground: #0c0b1a;
+
:export {
colorBrand0: bazarr.$color-brand-0;
colorBrand1: bazarr.$color-brand-1;
@@ -15,4 +17,6 @@ $navbar-width: 200;
headerHeight: bazarr.$header-height;
navBarWidth: $navbar-width;
+
+ surfaceGround: $surface-ground;
}
diff --git a/frontend/src/assets/action_icon.module.scss b/frontend/src/assets/action_icon.module.scss
index 3ff702f35b..79d71034d1 100644
--- a/frontend/src/assets/action_icon.module.scss
+++ b/frontend/src/assets/action_icon.module.scss
@@ -1,16 +1,18 @@
@layer mantine {
.root {
--ai-bg: transparent;
+ border-radius: var(--bz-radius-sm);
+ transition:
+ background var(--bz-duration-normal) var(--bz-ease-standard),
+ color var(--bz-duration-normal) var(--bz-ease-standard);
- @include mantine.light {
- color: var(--mantine-color-dark-2);
- --ai-hover: var(--mantine-color-gray-1);
- --ai-hover-color: var(--mantine-color-gray-1);
- }
+ color: var(--bz-text-secondary);
+ --ai-hover: var(--bz-hover-bg);
+ --ai-hover-color: var(--bz-text-primary);
- @include mantine.dark {
- color: var(--mantine-color-dark-0);
- --ai-hover: var(--mantine-color-gray-8);
+ &[data-variant="gradient"],
+ &[data-variant="filled"] {
+ color: #fff;
}
}
}
diff --git a/frontend/src/assets/badge.module.scss b/frontend/src/assets/badge.module.scss
index e0bd86a998..07c37e5a3e 100644
--- a/frontend/src/assets/badge.module.scss
+++ b/frontend/src/assets/badge.module.scss
@@ -1,51 +1,188 @@
-@use "sass:color";
-
@layer mantine {
.root {
- background-color: color.adjust(bazarr.$color-brand-6, $alpha: -0.8);
+ border-radius: var(--bz-radius-sm);
+ font-size: 10px;
+ font-weight: 600;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ padding: 2px 8px;
+ transition:
+ background var(--bz-duration-fast) var(--bz-ease-standard),
+ border-color var(--bz-duration-fast) var(--bz-ease-standard);
- &[data-variant="warning"] {
- color: color.adjust(bazarr.$color-warning-2, $lightness: 80%);
- background-color: color.adjust(bazarr.$color-warning-6, $alpha: -0.8);
+ // Default variant (ghost): neutral for audio tracks and general use
+ background-color: rgba(255, 255, 255, 0.05);
+ color: #a9aabb;
+ border: 1px solid rgba(255, 255, 255, 0.07);
+
+ &:hover {
+ background-color: rgba(255, 255, 255, 0.1);
+ border-color: rgba(255, 255, 255, 0.12);
}
+ // Highlight variant (green): available subtitles
&[data-variant="highlight"] {
- color: color.adjust(bazarr.$color-highlight-2, $lightness: 100%);
- background-color: color.adjust(bazarr.$color-highlight-5, $alpha: -0.8);
+ background: rgba(105, 219, 124, 0.12);
+ color: #69db7c;
+ border: 1px solid rgba(105, 219, 124, 0.18);
+
+ &:hover {
+ background: rgba(105, 219, 124, 0.17);
+ border-color: rgba(105, 219, 124, 0.23);
+ }
+ }
+
+ // Warning variant (amber): upgradeable subtitles
+ &[data-variant="warning"] {
+ background: rgba(230, 138, 0, 0.12);
+ color: #ffd54f;
+ border: 1px solid rgba(230, 138, 0, 0.18);
+
+ &:hover {
+ background: rgba(230, 138, 0, 0.17);
+ border-color: rgba(230, 138, 0, 0.23);
+ }
}
+ // Disabled variant (ghost dim): embedded/disabled subtitles
&[data-variant="disabled"] {
- color: color.adjust(bazarr.$color-disabled-0, $lightness: 100%);
- background-color: color.adjust(bazarr.$color-disabled-7, $alpha: -0.8);
+ background: rgba(255, 255, 255, 0.03);
+ color: #6c6d85;
+ border: 1px solid rgba(255, 255, 255, 0.04);
+
+ &:hover {
+ background: rgba(255, 255, 255, 0.06);
+ border-color: rgba(255, 255, 255, 0.08);
+ }
+ }
+
+ // HI subs variant (warm): hearing impaired subtitles
+ &[data-variant="hi"] {
+ background: rgba(179, 107, 0, 0.1);
+ color: #ffd54f;
+ border: 1px solid rgba(179, 107, 0, 0.18);
+
+ &:hover {
+ background: rgba(179, 107, 0, 0.15);
+ border-color: rgba(179, 107, 0, 0.23);
+ }
+ }
+
+ // Missing variant: missing subtitles
+ &[data-variant="missing"] {
+ background: rgba(255, 107, 107, 0.06);
+ color: #ff8787;
+ border: 1px dashed rgba(255, 107, 107, 0.4);
+
+ &:hover {
+ background: rgba(255, 107, 107, 0.11);
+ border-color: rgba(255, 107, 107, 0.5);
+ }
}
- &[data-variant="light"] {
- color: var(--mantine-color-dark-0);
- background-color: color.adjust(bazarr.$color-disabled-9, $alpha: -0.8);
+ // Preserve passthrough for Mantine built-in variants used elsewhere
+ &[data-variant="light"],
+ &[data-variant="outline"] {
+ color: unset;
+ background: unset;
+ background-color: unset;
+ border: unset;
}
+ &[data-variant="gradient"] {
+ color: #fff;
+ border: unset;
+ }
+
+ &[data-variant="transparent"] {
+ color: unset;
+ background: unset;
+ border: unset;
+ }
+
+ // Light mode overrides
@include mantine.light {
- color: bazarr.$color-brand-6;
- background-color: color.adjust(bazarr.$color-brand-3, $alpha: -0.8);
+ background-color: rgba(0, 0, 0, 0.05);
+ color: #495057;
+ border: 1px solid rgba(0, 0, 0, 0.08);
+
+ &:hover {
+ background-color: rgba(0, 0, 0, 0.08);
+ border-color: rgba(0, 0, 0, 0.12);
+ }
&[data-variant="warning"] {
- color: color.adjust(bazarr.$color-warning-7, $lightness: -100%);
- background-color: color.adjust(bazarr.$color-warning-6, $alpha: -0.8);
+ background: rgba(230, 138, 0, 0.1);
+ color: #995c00;
+ border: 1px solid rgba(230, 138, 0, 0.2);
+
+ &:hover {
+ background: rgba(230, 138, 0, 0.15);
+ border-color: rgba(230, 138, 0, 0.28);
+ }
}
&[data-variant="disabled"] {
- color: color.adjust(bazarr.$color-disabled-6, $lightness: -100%);
- background-color: color.adjust(bazarr.$color-disabled-4, $alpha: -0.8);
+ background: rgba(0, 0, 0, 0.03);
+ color: #adb5bd;
+ border: 1px solid rgba(0, 0, 0, 0.06);
+
+ &:hover {
+ background: rgba(0, 0, 0, 0.05);
+ border-color: rgba(0, 0, 0, 0.1);
+ }
}
&[data-variant="highlight"] {
- color: color.adjust(bazarr.$color-highlight-6, $lightness: -100%);
- background-color: color.adjust(bazarr.$color-highlight-5, $alpha: -0.8);
+ background: rgba(105, 219, 124, 0.1);
+ color: #2b8a3e;
+ border: 1px solid rgba(105, 219, 124, 0.2);
+
+ &:hover {
+ background: rgba(105, 219, 124, 0.15);
+ border-color: rgba(105, 219, 124, 0.28);
+ }
+ }
+
+ &[data-variant="missing"] {
+ background: rgba(255, 107, 107, 0.06);
+ color: #e03131;
+ border: 1px dashed rgba(255, 107, 107, 0.4);
+
+ &:hover {
+ background: rgba(255, 107, 107, 0.1);
+ border-color: rgba(255, 107, 107, 0.5);
+ }
+ }
+
+ &[data-variant="hi"] {
+ background: rgba(179, 107, 0, 0.1);
+ color: #7a4900;
+ border: 1px solid rgba(179, 107, 0, 0.2);
+
+ &:hover {
+ background: rgba(179, 107, 0, 0.15);
+ border-color: rgba(179, 107, 0, 0.28);
+ }
+ }
+
+ &[data-variant="light"],
+ &[data-variant="outline"] {
+ color: unset;
+ background: unset;
+ border: unset;
+ }
+
+ &[data-variant="gradient"] {
+ color: #fff;
+ background-color: transparent;
+ border: none;
}
- &[data-variant="light"] {
- color: var(--mantine-color-black);
- background-color: var(--mantine-color-gray-5);
+ &[data-variant="transparent"] {
+ color: unset;
+ background: unset;
+ border: unset;
}
}
}
diff --git a/frontend/src/assets/button.module.scss b/frontend/src/assets/button.module.scss
index 3ac8bca91f..14b9a3a879 100644
--- a/frontend/src/assets/button.module.scss
+++ b/frontend/src/assets/button.module.scss
@@ -1,7 +1,16 @@
@layer mantine {
.root {
- @include mantine.dark {
- color: var(--mantine-color-dark-0);
+ border-radius: var(--bz-radius-sm);
+ transition:
+ background var(--bz-duration-normal) var(--bz-ease-standard),
+ transform var(--bz-duration-normal) var(--bz-ease-standard),
+ box-shadow var(--bz-duration-normal) var(--bz-ease-standard);
+
+ color: var(--bz-text-primary);
+
+ &[data-variant="filled"],
+ &[data-variant="gradient"] {
+ color: #fff;
}
&[data-variant="danger"] {
@@ -11,8 +20,10 @@
}
.root:disabled {
- @include mantine.dark {
- color: var(--mantine-color-dark-9);
+ color: var(--bz-text-disabled);
+
+ @include mantine.light {
+ color: var(--bz-text-tertiary);
}
}
}
diff --git a/frontend/src/assets/input.module.scss b/frontend/src/assets/input.module.scss
new file mode 100644
index 0000000000..7d29daebcf
--- /dev/null
+++ b/frontend/src/assets/input.module.scss
@@ -0,0 +1,24 @@
+@layer mantine {
+ .input {
+ border-radius: var(--bz-radius-md);
+ background: var(--bz-hover-bg);
+ border: 1px solid var(--bz-border-interactive);
+ transition:
+ background var(--bz-duration-normal) var(--bz-ease-standard),
+ border-color var(--bz-duration-normal) var(--bz-ease-standard);
+
+ &::placeholder {
+ color: var(--bz-text-tertiary);
+ }
+
+ &:hover {
+ background: var(--bz-hover-bg-emphasis);
+ }
+
+ &:focus,
+ &:focus-within {
+ background: var(--bz-hover-bg-emphasis);
+ border-color: var(--bz-border-hover);
+ }
+ }
+}
diff --git a/frontend/src/assets/pagination.module.scss b/frontend/src/assets/pagination.module.scss
index 2b66d75103..f5c9e924bb 100644
--- a/frontend/src/assets/pagination.module.scss
+++ b/frontend/src/assets/pagination.module.scss
@@ -1,3 +1,15 @@
-.control {
- --pagination-active-bg: var(--mantine-color-brand-filled);
+@layer mantine {
+ .control {
+ --pagination-active-bg: var(--mantine-color-brand-filled);
+ border-radius: var(--bz-radius-xs);
+ transition:
+ background var(--bz-duration-normal) var(--bz-ease-standard),
+ border-color var(--bz-duration-normal) var(--bz-ease-standard);
+
+ color: var(--bz-text-secondary);
+
+ &:hover:not([data-active]) {
+ background: var(--bz-hover-bg);
+ }
+ }
}
diff --git a/frontend/src/assets/progress.module.scss b/frontend/src/assets/progress.module.scss
new file mode 100644
index 0000000000..3b99a26dd0
--- /dev/null
+++ b/frontend/src/assets/progress.module.scss
@@ -0,0 +1,9 @@
+$noise-svg: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.15'/%3E%3C/svg%3E");
+
+@layer mantine {
+ .section {
+ background-image: $noise-svg !important;
+ background-size: 128px 128px !important;
+ background-repeat: repeat !important;
+ }
+}
diff --git a/frontend/src/components/StateIcon.tsx b/frontend/src/components/StateIcon.tsx
index 23bb2d507d..4e164729fd 100644
--- a/frontend/src/components/StateIcon.tsx
+++ b/frontend/src/components/StateIcon.tsx
@@ -73,7 +73,7 @@ const StateIcon: FunctionComponent = ({
-
+
Scoring Criteria
diff --git a/frontend/src/components/SubtitleToolsMenu.tsx b/frontend/src/components/SubtitleToolsMenu.tsx
index 5f0fbc3c30..c92e073dbf 100644
--- a/frontend/src/components/SubtitleToolsMenu.tsx
+++ b/frontend/src/components/SubtitleToolsMenu.tsx
@@ -1,10 +1,12 @@
import { FunctionComponent, ReactElement, useCallback, useMemo } from "react";
import { Divider, List, Menu, MenuProps, ScrollArea } from "@mantine/core";
import {
+ faAlignJustify,
faClock,
faCode,
faDeaf,
faExchangeAlt,
+ faEye,
faFaceGrinStars,
faFilm,
faImage,
@@ -26,7 +28,9 @@ import { TranslationModal } from "@/components/forms/TranslationForm";
import { useModals } from "@/modules/modals";
import { ModalComponent } from "@/modules/modals/WithModal";
import { task } from "@/modules/task";
+import { toPython } from "@/utilities";
import { SyncSubtitleModal } from "./forms/SyncSubtitleForm";
+import { TwoPointFitModal } from "./forms/TwoPointFit";
export interface ToolOptions {
key: string;
@@ -99,6 +103,12 @@ export function useTools() {
name: "Adjust Times...",
modal: TimeOffsetModal,
},
+ {
+ key: "two_point_fit",
+ icon: faAlignJustify,
+ name: "Two-Point Fit...",
+ modal: TwoPointFitModal,
+ },
{
key: "translation",
icon: faLanguage,
@@ -114,7 +124,12 @@ interface Props {
selections: FormType.ModifySubtitle[];
children?: ReactElement;
menu?: Omit;
- onAction?: (action: "delete" | "search") => void;
+ onAction?: (action: "delete" | "search" | "view") => void;
+ // For missing subtitle translation
+ missingLanguage?: Subtitle;
+ translationSources?: Subtitle[];
+ mediaId?: number;
+ mediaType?: "episode" | "movie";
}
const SubtitleToolsMenu: FunctionComponent = ({
@@ -122,6 +137,10 @@ const SubtitleToolsMenu: FunctionComponent = ({
children,
menu,
onAction,
+ missingLanguage,
+ translationSources,
+ mediaId,
+ mediaType,
}) => {
const { mutateAsync } = useSubtitleAction();
@@ -146,30 +165,83 @@ const SubtitleToolsMenu: FunctionComponent = ({
const modals = useModals();
const disabledTools = selections.length === 0;
+ const isMissing = !!missingLanguage;
+ const hasSources = (translationSources ?? []).length > 0;
return (
{children}
Tools
- {tools.map((tool) => (
+ {tools.map((tool) => {
+ // "Translate" for missing subs: show as submenu with source options
+ if (tool.key === "translation" && isMissing) {
+ return null; // handled below in Actions
+ }
+
+ return (
+ }
+ onClick={() => {
+ if (tool.modal) {
+ modals.openContextModal(tool.modal, { selections });
+ } else {
+ process(tool.key, tool.name);
+ }
+ }}
+ >
+ {tool.name}
+
+ );
+ })}
+
+ Actions
+ {/* Translate from source โ for missing subtitles */}
+ {isMissing && hasSources && (
+ <>
+ {translationSources!.map((source) => (
+ }
+ onClick={async () => {
+ await mutateAsync({
+ action: "translate",
+ form: {
+ id: mediaId!,
+ type: mediaType!,
+ language: missingLanguage.code2,
+ path: source.path!,
+ forced: toPython(missingLanguage.forced),
+ hi: toPython(missingLanguage.hi),
+ },
+ });
+ }}
+ >
+ Translate from {source.name || source.code2}
+ {source.hi ? " (HI)" : ""}
+
+ ))}
+ >
+ )}
+ {isMissing && !hasSources && (
}
- onClick={() => {
- if (tool.modal) {
- modals.openContextModal(tool.modal, { selections });
- } else {
- process(tool.key, tool.name);
- }
- }}
+ disabled
+ leftSection={ }
>
- {tool.name}
+ No source subtitles to translate from
- ))}
-
- Actions
+ )}
+ }
+ onClick={() => {
+ onAction?.("view");
+ }}
+ >
+ View
+
}
diff --git a/frontend/src/components/TranslatorStatus.module.css b/frontend/src/components/TranslatorStatus.module.css
new file mode 100644
index 0000000000..70d62bcbd3
--- /dev/null
+++ b/frontend/src/components/TranslatorStatus.module.css
@@ -0,0 +1,63 @@
+@keyframes heartbeat {
+ 0%,
+ 100% {
+ opacity: 1;
+ transform: scale(1);
+ }
+ 14% {
+ opacity: 1;
+ transform: scale(1.15);
+ }
+ 28% {
+ opacity: 1;
+ transform: scale(1);
+ }
+ 42% {
+ opacity: 1;
+ transform: scale(1.1);
+ }
+ 56% {
+ opacity: 1;
+ transform: scale(1);
+ }
+}
+
+@keyframes spin {
+ from {
+ transform: rotate(0deg);
+ }
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+.promptConnected {
+ animation: heartbeat 1.5s ease-in-out infinite;
+ display: inline-block;
+}
+
+.statCard {
+ transition: var(--bz-transition-lift);
+ cursor: default;
+ border-left-width: 3px;
+ border-left-style: solid;
+}
+
+.statCard:hover {
+ transform: translateY(-2px);
+ background-color: var(--bz-surface-card-hover);
+}
+
+.spin {
+ animation: spin 1s linear infinite;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .promptConnected,
+ .spin {
+ animation: none;
+ }
+ .statCard:hover {
+ transform: none;
+ }
+}
diff --git a/frontend/src/components/TranslatorStatus.tsx b/frontend/src/components/TranslatorStatus.tsx
index bff899b218..44552d3c71 100644
--- a/frontend/src/components/TranslatorStatus.tsx
+++ b/frontend/src/components/TranslatorStatus.tsx
@@ -1,9 +1,7 @@
-import { FunctionComponent, useState, useCallback } from "react";
+import { FunctionComponent, useCallback, useState } from "react";
import {
- ActionIcon,
Alert,
Badge,
- Box,
Button,
Card,
Group,
@@ -17,22 +15,19 @@ import {
} from "@mantine/core";
import {
faCheck,
- faCircle,
faClock,
faExclamationTriangle,
faRefresh,
faSpinner,
faTimes,
- faTrash,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
TranslatorJob,
useTranslatorJobs,
useTranslatorStatus,
- useCancelTranslatorJob,
} from "@/apis/hooks/translator";
-import { useSettingValue } from "@/pages/Settings/utilities/hooks";
+import classes from "./TranslatorStatus.module.css";
interface StatusBadgeProps {
status: TranslatorJob["status"];
@@ -43,6 +38,7 @@ const StatusBadge: FunctionComponent = ({ status }) => {
queued: { color: "gray", icon: faClock, label: "Queued" },
processing: { color: "blue", icon: faSpinner, label: "Processing" },
completed: { color: "green", icon: faCheck, label: "Completed" },
+ partial: { color: "yellow", icon: faExclamationTriangle, label: "Partial" },
failed: { color: "red", icon: faExclamationTriangle, label: "Failed" },
cancelled: { color: "orange", icon: faTimes, label: "Cancelled" },
}[status];
@@ -55,6 +51,7 @@ const StatusBadge: FunctionComponent = ({ status }) => {
icon={config.icon}
spin={status === "processing"}
size="xs"
+ aria-hidden="true"
/>
}
>
@@ -65,55 +62,150 @@ const StatusBadge: FunctionComponent = ({ status }) => {
interface JobRowProps {
job: TranslatorJob;
- onCancel: (id: string) => void;
- isDeleting: boolean;
}
-const JobRow: FunctionComponent = ({
- job,
- onCancel,
- isDeleting,
-}) => {
- const canCancel = job.status === "queued";
+function formatDuration(seconds: number): string {
+ if (seconds < 60) return `${seconds.toFixed(0)}s`;
+ const min = Math.floor(seconds / 60);
+ const sec = Math.round(seconds % 60);
+ return `${min}m ${sec}s`;
+}
+
+function formatCostValue(cost: number): string {
+ if (cost === 0) return "Free";
+ if (cost < 0.01) return `$${cost.toFixed(4)}`;
+ return `$${cost.toFixed(2)}`;
+}
+
+const JobRow: FunctionComponent = ({ job }) => {
+ const modelUsed = job.result?.model_used || job.model;
+ // Prefer top-level tokensUsed (live from API), fall back to result
+ const tokensUsed = job.tokensUsed || job.result?.tokens_used;
+
+ // Build media title
+ const langPair =
+ job.sourceLanguage && job.targetLanguage
+ ? ` (${job.sourceLanguage.toUpperCase()} โ ${job.targetLanguage.toUpperCase()})`
+ : "";
+ const mediaTitle = job.title
+ ? `${job.title}${langPair}`
+ : job.filename
+ ? `${job.filename}${langPair}`
+ : langPair || "-";
+
+ // Duration โ prefer server-side elapsedSeconds
+ let durationStr = "-";
+ let durationSec = 0;
+ if (job.elapsedSeconds) {
+ durationSec = job.elapsedSeconds;
+ durationStr = formatDuration(durationSec);
+ } else if (job.startedAt && job.completedAt) {
+ durationSec =
+ (new Date(job.completedAt).getTime() -
+ new Date(job.startedAt).getTime()) /
+ 1000;
+ durationStr = formatDuration(durationSec);
+ } else if (job.status === "processing" && job.startedAt) {
+ // eslint-disable-next-line react-hooks/purity
+ const elapsed = (Date.now() - new Date(job.startedAt).getTime()) / 1000;
+ durationStr = `${formatDuration(elapsed)}...`;
+ }
+
+ // TPS
+ const tpsStr =
+ tokensUsed && durationSec > 0
+ ? `${(tokensUsed / durationSec).toFixed(0)} t/s`
+ : "-";
+
+ // Progress โ show lines if available
+ const progressLabel =
+ job.completedLines && job.totalLines
+ ? `${job.completedLines}/${job.totalLines} lines`
+ : `${job.progress}%`;
+
+ // Batch info for tooltip
+ const batchInfo =
+ job.completedBatches && job.totalBatches
+ ? `Batch ${job.completedBatches}/${job.totalBatches}`
+ : undefined;
return (
-
-
- {job.jobId.substring(0, 8)}...
+
+
+ {mediaTitle}
-
+ {job.error ? (
+
+
+
+
+
+ ) : (
+
+ )}
{job.status === "processing" ? (
-
+
+
+
) : (
- {job.progress}%
+ {progressLabel}
)}
-
- {job.message || job.filename || "-"}
+
+ {modelUsed || "-"}
+
+
+
+
+ {tokensUsed ? tokensUsed.toLocaleString() : "-"}
+
+
+
+
+ {job.totalCost != null && job.totalCost > 0 ? (
+
+
+ {formatCostValue(job.totalCost)}
+
+
+ ) : (
+ "-"
+ )}
- {new Date(job.createdAt).toLocaleTimeString()}
+
+ {durationStr}
+
- {canCancel && (
- onCancel(job.jobId)}
- loading={isDeleting}
- >
-
-
- )}
+
+ {tpsStr}
+
);
@@ -130,27 +222,35 @@ const StatCard: FunctionComponent = ({
value,
color,
}) => (
-
-
- {label}
-
-
+
+
{value}
+
+ {label}
+
);
interface TranslatorStatusPanelProps {
enabled?: boolean;
- savedApiKey?: string;
- savedModel?: string;
- savedMaxConcurrent?: number;
}
export const TranslatorStatusPanel: FunctionComponent<
TranslatorStatusPanelProps
-> = ({ enabled = true, savedApiKey, savedModel, savedMaxConcurrent }) => {
- const [retryKey, setRetryKey] = useState(0);
+> = ({ enabled = true }) => {
+ const [, setRetryKey] = useState(0);
const {
data: status,
@@ -164,8 +264,6 @@ export const TranslatorStatusPanel: FunctionComponent<
isError: jobsError,
refetch: refetchJobs,
} = useTranslatorJobs(enabled && !statusError);
- const cancelJob = useCancelTranslatorJob();
-
const handleRetry = useCallback(() => {
setRetryKey((k) => k + 1);
void refetchStatus();
@@ -177,8 +275,10 @@ export const TranslatorStatusPanel: FunctionComponent<
return (
-
- Connecting to AI Subtitle Translator...
+
+
+ Connecting to AI Subtitle Translator...
+
);
@@ -195,7 +295,9 @@ export const TranslatorStatusPanel: FunctionComponent<
color="yellow"
title="AI Subtitle Translator Unavailable"
mt="md"
- icon={ }
+ icon={
+
+ }
>
{errorMessage}
@@ -203,7 +305,7 @@ export const TranslatorStatusPanel: FunctionComponent<
}
+ leftSection={ }
onClick={handleRetry}
>
Retry Connection
@@ -212,137 +314,96 @@ export const TranslatorStatusPanel: FunctionComponent<
);
}
- const handleCancel = (jobId: string) => {
- cancelJob.mutate(jobId);
- };
-
return (
{/* Service Status */}
AI Subtitle Translator Service
- {status?.healthy ? (
- }
- >
- Connected
-
- ) : (
- Disconnected
- )}
-
-
- {/* Bazarr Configuration (Saved Settings) */}
-
- Bazarr Configuration
-
-
-
-
- API Key
-
- {savedApiKey ? "โ Set" : "โ Not Set"}
-
-
-
- Model
-
- {savedModel || "Not configured"}
-
-
-
- Max Concurrent
-
- {savedMaxConcurrent ?? "Not set"}
-
-
-
- {/* Service Status (Runtime State) */}
- {status && (
- <>
-
- Service Runtime State
-
-
-
-
- Active Model
-
- {status.config.model}
-
-
-
- API Key Status
-
-
- {status.config.apiKeyConfigured
- ? "โ Configured"
- : "โ Not Set"}
-
-
-
-
- Max Concurrent
-
- {status.queue.maxConcurrent}
-
+
+
+
+ โฏ
+
+
+ {status?.healthy ? "Connected" : "Disconnected"}
+
- >
- )}
+
+
{/* Queue Stats */}
{status && (
-
+
+ {(status.bazarr_queue?.pending ?? 0) > 0 && (
+
+ )}
-
-
+
)}
{/* Jobs Table */}
-
+
Translation Jobs
{jobsData && jobsData.jobs.length > 0 ? (
-
+
- Job ID
+ Media
Status
Progress
- Message
- Created
- Actions
+ Model
+ Tokens
+ Cost
+ Duration
+ Speed
{jobsData.jobs.map((job) => (
-
+
))}
) : (
-
+
No translation jobs
)}
@@ -356,24 +417,7 @@ export const TranslatorStatusPanel: FunctionComponent<
* This must be rendered inside a FormContext (i.e., inside Layout component).
*/
export const TranslatorStatusPanelWithFormContext: FunctionComponent = () => {
- // Use useSettingValue which properly merges saved settings with staged overrides
- const savedApiKey = useSettingValue(
- "settings-translator-openrouter_api_key",
- );
- const savedModel = useSettingValue(
- "settings-translator-openrouter_model",
- );
- const savedMaxConcurrent = useSettingValue(
- "settings-translator-openrouter_max_concurrent",
- );
-
- return (
-
- );
+ return ;
};
export default TranslatorStatusPanel;
diff --git a/frontend/src/components/bazarr/AudioList.tsx b/frontend/src/components/bazarr/AudioList.tsx
index 7620579ff8..6ffb87ec18 100644
--- a/frontend/src/components/bazarr/AudioList.tsx
+++ b/frontend/src/components/bazarr/AudioList.tsx
@@ -19,7 +19,7 @@ const AudioList: FunctionComponent = ({
return (
{audios.map((audio, idx) => (
-
+
{normalizeAudioLanguage(audio.name)}
))}
diff --git a/frontend/src/components/bazarr/LanguageSelector.tsx b/frontend/src/components/bazarr/LanguageSelector.tsx
index 8954403bd2..2395098514 100644
--- a/frontend/src/components/bazarr/LanguageSelector.tsx
+++ b/frontend/src/components/bazarr/LanguageSelector.tsx
@@ -3,8 +3,10 @@ import { useLanguages } from "@/apis/hooks";
import { Selector, SelectorProps } from "@/components/inputs";
import { useSelectorOptions } from "@/utilities";
-interface LanguageSelectorProps
- extends Omit, "options" | "getkey"> {
+interface LanguageSelectorProps extends Omit<
+ SelectorProps,
+ "options" | "getkey"
+> {
enabled?: boolean;
}
diff --git a/frontend/src/components/forms/BatchModConfirmForm.tsx b/frontend/src/components/forms/BatchModConfirmForm.tsx
new file mode 100644
index 0000000000..e955702b82
--- /dev/null
+++ b/frontend/src/components/forms/BatchModConfirmForm.tsx
@@ -0,0 +1,103 @@
+import { FunctionComponent } from "react";
+import { Button, Divider, Group, Stack, Text } from "@mantine/core";
+import { notifications } from "@mantine/notifications";
+import { useBatchAction } from "@/apis/hooks";
+import { BatchAction, BatchItem } from "@/apis/raw/subtitles";
+import { useModals, withModal } from "@/modules/modals";
+
+/* eslint-disable camelcase -- keys match backend action identifiers */
+const ACTION_LABELS: Record = {
+ OCR_fixes: "OCR Fixes",
+ common: "Common Fixes",
+ remove_HI: "Remove Hearing Impaired",
+ remove_tags: "Remove Style Tags",
+ fix_uppercase: "Fix Uppercase",
+ reverse_rtl: "Reverse RTL",
+ "scan-disk": "Scan Disk",
+ "search-missing": "Search Missing Subtitles",
+ upgrade: "Upgrade Subtitles",
+};
+/* eslint-enable camelcase */
+
+interface BatchModConfirmFormProps {
+ items: BatchItem[];
+ action: BatchAction;
+}
+
+const BatchModConfirmForm: FunctionComponent = ({
+ items,
+ action,
+}) => {
+ const { mutateAsync, isPending } = useBatchAction();
+ const modals = useModals();
+ const label = ACTION_LABELS[action] ?? action;
+
+ const handleSubmit = async () => {
+ if (items.length >= 100) {
+ const confirmed = window.confirm(
+ `This will apply "${label}" to ${items.length} items. This may take a while. Continue?`,
+ );
+ if (!confirmed) return;
+ }
+
+ try {
+ const result = await mutateAsync({ items, action });
+
+ notifications.show({
+ title: `${label} Queued`,
+ message: `Queued: ${result.queued}, Skipped: ${result.skipped}${result.errors.length > 0 ? `, Errors: ${result.errors.length}` : ""}`,
+ color: result.errors.length > 0 ? "yellow" : "green",
+ });
+
+ modals.closeSelf();
+ } catch (error) {
+ notifications.show({
+ title: `${label} Failed`,
+ message: String(error),
+ color: "red",
+ });
+ }
+ };
+
+ return (
+
+ );
+};
+
+export const BatchModConfirmModal = withModal(
+ BatchModConfirmForm,
+ "batch-mod-confirm",
+ {
+ title: "Confirm Batch Operation",
+ size: "md",
+ },
+);
+
+export default BatchModConfirmForm;
diff --git a/frontend/src/components/forms/ChangeProfileForm.tsx b/frontend/src/components/forms/ChangeProfileForm.tsx
new file mode 100644
index 0000000000..e909e8c5e8
--- /dev/null
+++ b/frontend/src/components/forms/ChangeProfileForm.tsx
@@ -0,0 +1,61 @@
+import { FunctionComponent, useMemo } from "react";
+import { Button, Stack, Text } from "@mantine/core";
+import { useLanguageProfiles } from "@/apis/hooks";
+import { useModals, withModal } from "@/modules/modals";
+
+interface ChangeProfileFormProps {
+ onSelect: (profileId: number | null) => void;
+}
+
+const ChangeProfileForm: FunctionComponent = ({
+ onSelect,
+}) => {
+ const { data: profiles } = useLanguageProfiles();
+ const modals = useModals();
+
+ const sortedProfiles = useMemo(
+ () => (profiles ?? []).slice().sort((a, b) => a.name.localeCompare(b.name)),
+ [profiles],
+ );
+
+ const handleSelect = (profileId: number | null) => {
+ onSelect(profileId);
+ modals.closeSelf();
+ };
+
+ return (
+
+ handleSelect(null)}
+ justify="flex-start"
+ >
+ Clear Profile
+
+ {sortedProfiles.map((profile) => (
+ handleSelect(profile.profileId)}
+ justify="flex-start"
+ >
+ {profile.name}
+
+ ))}
+
+ );
+};
+
+export const ChangeProfileModal = withModal(
+ ChangeProfileForm,
+ "change-profile",
+ {
+ title: "Change Profile",
+ size: "sm",
+ },
+);
+
+export default ChangeProfileForm;
diff --git a/frontend/src/components/forms/ItemEditForm.tsx b/frontend/src/components/forms/ItemEditForm.tsx
index 6154913c9e..74cedb9aca 100644
--- a/frontend/src/components/forms/ItemEditForm.tsx
+++ b/frontend/src/components/forms/ItemEditForm.tsx
@@ -1,10 +1,12 @@
import { FunctionComponent, useMemo } from "react";
import { Button, Divider, Group, LoadingOverlay, Stack } from "@mantine/core";
import { useForm } from "@mantine/form";
+import { showNotification } from "@mantine/notifications";
import { UseMutationResult } from "@tanstack/react-query";
import { useLanguageProfiles } from "@/apis/hooks";
import { MultiSelector, Selector } from "@/components/inputs";
import { useModals, withModal } from "@/modules/modals";
+import { notification } from "@/modules/task";
import { GetItemId, useSelectorOptions } from "@/utilities";
interface Props {
@@ -56,9 +58,29 @@ const ItemEditForm: FunctionComponent = ({
if (item) {
const itemId = GetItemId(item);
if (itemId) {
- mutate({ id: [itemId], profileid: [profile?.profileId ?? null] });
- onComplete?.();
- modals.closeSelf();
+ mutate(
+ { id: [itemId], profileid: [profile?.profileId ?? null] },
+ {
+ onSuccess: () => {
+ showNotification(
+ notification.info(
+ "Profile Saved",
+ "Languages profile updated successfully",
+ ),
+ );
+ onComplete?.();
+ modals.closeSelf();
+ },
+ onError: () => {
+ showNotification(
+ notification.error(
+ "Save Failed",
+ "Could not update languages profile",
+ ),
+ );
+ },
+ },
+ );
return;
}
}
diff --git a/frontend/src/components/forms/MassSyncForm.tsx b/frontend/src/components/forms/MassSyncForm.tsx
new file mode 100644
index 0000000000..465152032a
--- /dev/null
+++ b/frontend/src/components/forms/MassSyncForm.tsx
@@ -0,0 +1,122 @@
+import { FunctionComponent } from "react";
+import {
+ Button,
+ Checkbox,
+ Divider,
+ Group,
+ NumberInput,
+ Stack,
+ Text,
+} from "@mantine/core";
+import { useForm } from "@mantine/form";
+import { notifications } from "@mantine/notifications";
+import { useBatchAction, useSystemSettings } from "@/apis/hooks";
+import { BatchItem, BatchOptions } from "@/apis/raw/subtitles";
+import { useModals, withModal } from "@/modules/modals";
+
+interface MassSyncFormProps {
+ items: BatchItem[];
+}
+
+const MassSyncForm: FunctionComponent = ({ items }) => {
+ const { data: settings } = useSystemSettings();
+ const { mutateAsync, isPending } = useBatchAction();
+ const modals = useModals();
+
+ const form = useForm({
+ initialValues: {
+ maxOffsetSeconds: settings?.subsync.max_offset_seconds ?? 60,
+ noFixFramerate: settings?.subsync.no_fix_framerate ?? true,
+ gss: settings?.subsync.gss ?? true,
+ forceResync: false,
+ },
+ });
+
+ const handleSubmit = form.onSubmit(async (values) => {
+ if (items.length >= 100) {
+ const confirmed = window.confirm(
+ `This will sync subtitles for ${items.length} items. This may take a while. Continue?`,
+ );
+ if (!confirmed) return;
+ }
+
+ try {
+ const result = await mutateAsync({
+ items,
+ action: "sync",
+ options: values,
+ });
+
+ notifications.show({
+ title: "Mass Sync Queued",
+ message: `Queued: ${result.queued}, Skipped: ${result.skipped}${result.errors.length > 0 ? `, Errors: ${result.errors.length}` : ""}`,
+ color: result.errors.length > 0 ? "yellow" : "green",
+ });
+
+ modals.closeSelf();
+ } catch (error) {
+ notifications.show({
+ title: "Mass Sync Failed",
+ message: String(error),
+ color: "red",
+ });
+ }
+ });
+
+ return (
+
+ );
+};
+
+export const MassSyncModal = withModal(MassSyncForm, "mass-sync", {
+ title: "Mass Sync Subtitles",
+ size: "md",
+});
+
+export default MassSyncForm;
diff --git a/frontend/src/components/forms/MassTranslateForm.tsx b/frontend/src/components/forms/MassTranslateForm.tsx
index 353d10b9dd..71c3afde17 100644
--- a/frontend/src/components/forms/MassTranslateForm.tsx
+++ b/frontend/src/components/forms/MassTranslateForm.tsx
@@ -1,23 +1,23 @@
import { FunctionComponent, useMemo } from "react";
import {
Alert,
+ Badge,
Button,
Divider,
+ Group,
Stack,
Text,
- Group,
- Badge,
} from "@mantine/core";
import { useForm } from "@mantine/form";
+import { notifications } from "@mantine/notifications";
import { isObject } from "lodash";
-import { useBatchTranslate, useSystemSettings } from "@/apis/hooks";
+import { useBatchAction, useSystemSettings } from "@/apis/hooks";
+import { BatchItem } from "@/apis/raw/subtitles";
import { Selector } from "@/components/inputs";
import { useModals, withModal } from "@/modules/modals";
import { useSelectorOptions } from "@/utilities";
import FormUtils from "@/utilities/form";
import { useEnabledLanguages } from "@/utilities/languages";
-import { BatchTranslateItem } from "@/apis/raw/subtitles";
-import { notifications } from "@mantine/notifications";
// Translations map for Google Translate compatibility
const googleTranslations: Record = {
@@ -143,7 +143,13 @@ export interface WantedMovieItem {
title: string;
}
-export type WantedItem = WantedEpisodeItem | WantedMovieItem;
+export interface WantedSeriesItem {
+ type: "series";
+ sonarrSeriesId: number;
+ title: string;
+}
+
+export type WantedItem = WantedEpisodeItem | WantedMovieItem | WantedSeriesItem;
interface Props {
items: WantedItem[];
@@ -157,7 +163,7 @@ interface TranslationConfig {
const MassTranslateForm: FunctionComponent = ({ items, onComplete }) => {
const settings = useSystemSettings();
- const { mutateAsync, isPending } = useBatchTranslate();
+ const { mutateAsync, isPending } = useBatchAction();
const modals = useModals();
const { data: languages } = useEnabledLanguages();
@@ -172,10 +178,19 @@ const MassTranslateForm: FunctionComponent = ({ items, onComplete }) => {
isObject,
"Please select a source language",
),
- targetLanguage: FormUtils.validation(
- isObject,
- "Please select a target language",
- ),
+ targetLanguage: (
+ value: Language.Info | null,
+ values: { sourceLanguage: Language.Info | null },
+ ) => {
+ if (!isObject(value)) return "Please select a target language";
+ if (
+ value &&
+ values.sourceLanguage &&
+ value.code2 === values.sourceLanguage.code2
+ )
+ return "Target language must be different from source";
+ return null;
+ },
},
});
@@ -243,43 +258,48 @@ const MassTranslateForm: FunctionComponent = ({ items, onComplete }) => {
}) => {
if (!values.sourceLanguage || !values.targetLanguage) return;
- const batchItems: BatchTranslateItem[] = items.map((item) => {
+ if (items.length >= 100) {
+ const confirmed = window.confirm(
+ `This will translate subtitles for ${items.length} items. This may take a while. Continue?`,
+ );
+ if (!confirmed) return;
+ }
+
+ const batchItems: BatchItem[] = items.map((item) => {
if (item.type === "episode") {
return {
type: "episode" as const,
sonarrSeriesId: item.sonarrSeriesId,
sonarrEpisodeId: item.sonarrEpisodeId,
- sourceLanguage: values.sourceLanguage!.code2,
- targetLanguage: values.targetLanguage!.code2,
+ };
+ } else if (item.type === "series") {
+ return {
+ type: "series" as const,
+ sonarrSeriesId: item.sonarrSeriesId,
};
} else {
return {
type: "movie" as const,
radarrId: item.radarrId,
- sourceLanguage: values.sourceLanguage!.code2,
- targetLanguage: values.targetLanguage!.code2,
};
}
});
try {
- const result = await mutateAsync(batchItems);
-
- if (result.queued > 0) {
- notifications.show({
- title: "Translation Queued",
- message: `${result.queued} item(s) queued for translation${result.skipped > 0 ? `, ${result.skipped} skipped` : ""}`,
- color: "green",
- });
- }
+ const result = await mutateAsync({
+ items: batchItems,
+ action: "translate",
+ options: {
+ fromLang: values.sourceLanguage!.code2,
+ toLang: values.targetLanguage!.code2,
+ },
+ });
- if (result.errors.length > 0) {
- notifications.show({
- title: "Some translations failed",
- message: result.errors.slice(0, 3).join("; "),
- color: "yellow",
- });
- }
+ notifications.show({
+ title: "Translation Queued",
+ message: `Queued: ${result.queued}, Skipped: ${result.skipped}${result.errors.length > 0 ? `, Errors: ${result.errors.length}` : ""}`,
+ color: result.errors.length > 0 ? "yellow" : "green",
+ });
onComplete?.();
modals.closeSelf();
@@ -301,7 +321,7 @@ const MassTranslateForm: FunctionComponent = ({ items, onComplete }) => {
{translatorModel} will be used to translate{" "}
{items.length} item(s).
-
+
You can choose translation service in the subtitles settings.
@@ -324,7 +344,7 @@ const MassTranslateForm: FunctionComponent = ({ items, onComplete }) => {
)}
{items.length > 5 && (
-
+
{items.length} items selected for translation
)}
@@ -361,9 +381,18 @@ const MassTranslateForm: FunctionComponent = ({ items, onComplete }) => {
-
- Translate {items.length} Item(s)
-
+
+ modals.closeSelf()}>
+ Cancel
+
+
+ Translate {items.length} Item(s)
+
+
);
diff --git a/frontend/src/components/forms/TwoPointFit.tsx b/frontend/src/components/forms/TwoPointFit.tsx
new file mode 100644
index 0000000000..d4aa6aae54
--- /dev/null
+++ b/frontend/src/components/forms/TwoPointFit.tsx
@@ -0,0 +1,269 @@
+import { FunctionComponent, useEffect, useMemo } from "react";
+import {
+ Alert,
+ Button,
+ Card,
+ Divider,
+ Group,
+ LoadingOverlay,
+ NumberInput,
+ Stack,
+} from "@mantine/core";
+import { useForm } from "@mantine/form";
+import { useSubtitleAction, useSubtitleContents } from "@/apis/hooks";
+import { Selector } from "@/components/inputs";
+import { useModals, withModal } from "@/modules/modals";
+import { task } from "@/modules/task";
+import { useSelectorOptions } from "@/utilities";
+
+const TaskName = "Two-Point Fit";
+
+function convertToAction(
+ r: { hour: number; min: number; sec: number; ms: number }, // offset to zero
+ o: { hour: number; min: number; sec: number; ms: number }, // offset
+ s: { from: number; to: number }, // scale
+) {
+ return `two_point_fit(rh=${r.hour},rm=${r.min},rs=${r.sec},rms=${r.ms},oh=${o.hour},om=${o.min},os=${o.sec},oms=${o.ms},from=${s.from},to=${s.to})`;
+}
+
+const totalMs = (t: { hour: number; min: number; sec: number; ms: number }) =>
+ t.hour * 3600000 + t.min * 60000 + t.sec * 1000 + t.ms;
+
+const lineStartMs = (t: SubtitleContents.LineTime) =>
+ t.total_seconds * 1000 + Math.round(t.microseconds / 1000);
+
+const lineStartToTime = (t: SubtitleContents.LineTime) => ({
+ hour: t.hours,
+ min: t.minutes,
+ sec: t.seconds,
+ ms: Math.round(t.microseconds / 1000),
+});
+
+interface Props {
+ selections: FormType.ModifySubtitle[];
+ onSubmit?: VoidFunction;
+}
+
+const TwoPointFitForm: FunctionComponent = ({
+ selections,
+ onSubmit,
+}) => {
+ const { mutateAsync } = useSubtitleAction();
+ const modals = useModals();
+
+ const query = useSubtitleContents(selections[0].path);
+ const lines = useMemo(() => query.data ?? [], [query]);
+
+ const form = useForm({
+ initialValues: {
+ first: {
+ line: null as SubtitleContents.Line | null,
+ to: { hour: 0, min: 0, sec: 0, ms: 0 },
+ },
+ last: {
+ line: null as SubtitleContents.Line | null,
+ to: { hour: 0, min: 0, sec: 0, ms: 0 },
+ },
+ },
+ validate: {
+ first: {
+ line: (v) => (v == null ? "Please select a line" : null),
+ },
+ last: {
+ line: (v, values) => {
+ if (v == null) return "Please select a line";
+ if (values.first.line != null && v.index === values.first.line.index)
+ return "Must be a different line than the first";
+ return null;
+ },
+ },
+ },
+ });
+
+ // Preselect default lines when data loads
+ useEffect(() => {
+ if (lines.length === 0) return;
+
+ const firstLine = lines.length < 50 ? lines[0] : lines[9];
+ const lastLine =
+ lines.length < 50 ? lines[lines.length - 1] : lines[lines.length - 10];
+
+ form.setFieldValue("first.line", firstLine);
+ form.setFieldValue("first.to", lineStartToTime(firstLine.start));
+ form.setFieldValue("last.line", lastLine);
+ form.setFieldValue("last.to", lineStartToTime(lastLine.start));
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [lines]);
+
+ const wrongOrder = useMemo(() => {
+ const { first, last } = form.values;
+ if (first.line == null || last.line == null) return false;
+ return lineStartMs(first.line.start) >= lineStartMs(last.line.start);
+ }, [form.values]);
+
+ const decimals = lines.length.toString().length;
+ const options = useSelectorOptions(
+ lines,
+ (v) =>
+ `${String(v.index).padStart(decimals, "0")}: ${String(
+ v.content,
+ ).replaceAll("\n", " ")}`,
+ (v) => String(v.index),
+ );
+
+ return (
+
+ );
+};
+
+export const TwoPointFitModal = withModal(
+ TwoPointFitForm,
+ "two-point-alignment",
+ {
+ title: "Two-Point Fit",
+ },
+);
+
+export default TwoPointFitForm;
diff --git a/frontend/src/components/inputs/ChipInput.test.tsx b/frontend/src/components/inputs/ChipInput.test.tsx
index b974274b8c..7bdb3d6535 100644
--- a/frontend/src/components/inputs/ChipInput.test.tsx
+++ b/frontend/src/components/inputs/ChipInput.test.tsx
@@ -43,6 +43,6 @@ describe("ChipInput", () => {
await userEvent.click(createBtn);
- expect(mockedFn).toBeCalledTimes(1);
+ expect(mockedFn).toHaveBeenCalledTimes(1);
});
});
diff --git a/frontend/src/components/inputs/DropContent.tsx b/frontend/src/components/inputs/DropContent.tsx
index c4bcf28777..390ad60920 100644
--- a/frontend/src/components/inputs/DropContent.tsx
+++ b/frontend/src/components/inputs/DropContent.tsx
@@ -23,7 +23,7 @@ export const DropContent: FunctionComponent = () => {
Upload Subtitles
-
+
Attach as many files as you like, you will need to select file
metadata before uploading
diff --git a/frontend/src/components/modals/ManualSearchModal.tsx b/frontend/src/components/modals/ManualSearchModal.tsx
index 61fcc3718c..154aa13456 100644
--- a/frontend/src/components/modals/ManualSearchModal.tsx
+++ b/frontend/src/components/modals/ManualSearchModal.tsx
@@ -62,7 +62,7 @@ function ManualSearchView(props: Props) {
);
if (releaseInfo.length === 0) {
- return Cannot get release info ;
+ return Cannot get release info ;
}
return (
diff --git a/frontend/src/components/tables/BaseTable.module.scss b/frontend/src/components/tables/BaseTable.module.scss
index e1e1eff0ba..62d9ddb936 100644
--- a/frontend/src/components/tables/BaseTable.module.scss
+++ b/frontend/src/components/tables/BaseTable.module.scss
@@ -5,5 +5,81 @@
}
.table {
- border-collapse: collapse;
+ border-collapse: separate;
+ border-spacing: 0 2px;
+ min-width: 700px;
+
+ // Column headers
+ :global(.mantine-Table-th) {
+ font-size: 10px;
+ font-weight: 600;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+ color: var(--bz-text-disabled);
+ border-bottom: none;
+ padding: 8px 12px;
+ }
+
+ // All table rows
+ :global(.mantine-Table-tr) {
+ border-radius: var(--bz-radius-md);
+ transition: background-color var(--bz-duration-normal)
+ var(--bz-ease-standard);
+ }
+
+ // Data rows (non-grouped)
+ :global(.mantine-Table-tr):not(.groupRow) {
+ cursor: pointer;
+
+ &:hover {
+ background-color: var(--bz-hover-bg);
+ }
+ }
+
+ // All table cells
+ :global(.mantine-Table-td) {
+ padding: 8px 12px;
+ border-bottom: none;
+ border-top: none;
+ }
+
+ // Group header row
+ .groupRow {
+ cursor: pointer;
+
+ &:hover {
+ background-color: var(--bz-hover-bg);
+ }
+ }
+}
+
+// Episode number styling
+.episodeNumber {
+ font-variant-numeric: tabular-nums;
+ color: var(--bz-text-disabled);
+}
+
+// Episode title styling
+.episodeTitle {
+ font-weight: 450;
+ color: var(--bz-text-secondary);
+}
+
+// Action icon styling
+.actionIcon {
+ width: 24px;
+ height: 24px;
+ min-width: 24px;
+ min-height: 24px;
+ border-radius: var(--bz-radius-sm);
+ background: transparent;
+ color: var(--bz-text-disabled);
+ transition:
+ background-color var(--bz-duration-normal) var(--bz-ease-standard),
+ color var(--bz-duration-normal) var(--bz-ease-standard);
+
+ &:hover {
+ background-color: var(--bz-hover-bg);
+ color: var(--bz-text-tertiary);
+ }
}
diff --git a/frontend/src/components/tables/BaseTable.tsx b/frontend/src/components/tables/BaseTable.tsx
index fedd9a11bf..dfe6c880c7 100644
--- a/frontend/src/components/tables/BaseTable.tsx
+++ b/frontend/src/components/tables/BaseTable.tsx
@@ -98,11 +98,7 @@ export default function BaseTable(props: BaseTableProps) {
return (
-
+
{instance.getHeaderGroups().map((headerGroup) => (
diff --git a/frontend/src/components/tables/GroupTable.tsx b/frontend/src/components/tables/GroupTable.tsx
index b14edf3e69..80fd76d4bf 100644
--- a/frontend/src/components/tables/GroupTable.tsx
+++ b/frontend/src/components/tables/GroupTable.tsx
@@ -11,6 +11,7 @@ import {
Row,
} from "@tanstack/react-table";
import SimpleTable, { SimpleTableProps } from "@/components/tables/SimpleTable";
+import styles from "@/components/tables/BaseTable.module.scss";
function renderCell(
cell: Cell,
@@ -35,7 +36,11 @@ function renderRow(row: Row) {
const rotation = row.getIsExpanded() ? 90 : undefined;
return (
-
+
row.toggleExpanded()}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
diff --git a/frontend/src/components/tables/PageControl.tsx b/frontend/src/components/tables/PageControl.tsx
index e328de7cfe..d72ed7e88a 100644
--- a/frontend/src/components/tables/PageControl.tsx
+++ b/frontend/src/components/tables/PageControl.tsx
@@ -1,5 +1,5 @@
import { FunctionComponent } from "react";
-import { Group, Pagination, Text } from "@mantine/core";
+import { Group, NativeSelect, Pagination, Text } from "@mantine/core";
import { useIsLoading } from "@/contexts";
interface Props {
@@ -8,6 +8,7 @@ interface Props {
size: number;
total: number;
goto: (idx: number) => void;
+ onPageSizeChange?: (size: number) => void;
}
const PageControl: FunctionComponent = ({
@@ -16,6 +17,7 @@ const PageControl: FunctionComponent = ({
size,
total,
goto,
+ onPageSizeChange,
}) => {
const empty = total === 0;
const start = empty ? 0 : size * index + 1;
@@ -25,9 +27,34 @@ const PageControl: FunctionComponent = ({
return (
-
- Show {start} to {end} of {total} entries
-
+
+
+ Show {start} to {end} of {total} entries
+
+ {onPageSizeChange && (
+ = total && total > 0 ? "all" : String(size)}
+ onChange={(e) => {
+ const val = e.currentTarget.value;
+ if (val === "all") {
+ onPageSizeChange(total);
+ } else {
+ onPageSizeChange(Number(val));
+ }
+ goto(0);
+ }}
+ data={[
+ { value: "25", label: "25 per page" },
+ { value: "50", label: "50 per page" },
+ { value: "100", label: "100 per page" },
+ { value: "250", label: "250 per page" },
+ { value: "all", label: "All" },
+ ]}
+ w={130}
+ />
+ )}
+
(props: Props) {
} = query;
const [searchParams, setSearchParams] = useSearchParams();
+ const [localPageSize, setLocalPageSize] = useState(null);
+ const effectivePageSize = localPageSize ?? pageSize;
useEffect(() => {
ScrollToTop();
@@ -45,25 +47,33 @@ export default function QueryPageTable(props: Props) {
// When normal: show server-provided page data (optionally filtered within page)
const displayData = useMemo(() => {
if (fetchAll) {
- const start = page * pageSize;
- return filteredData.slice(start, start + pageSize);
+ const start = page * effectivePageSize;
+ return filteredData.slice(start, start + effectivePageSize);
+ }
+ // In server-paginated mode, slice to effectivePageSize if smaller than fetched
+ if (localPageSize !== null && localPageSize < filteredData.length) {
+ return filteredData.slice(0, localPageSize);
}
return filteredData;
- }, [fetchAll, filteredData, page, pageSize]);
+ }, [fetchAll, filteredData, page, effectivePageSize, localPageSize]);
// Calculate counts based on mode
const effectiveTotalCount = fetchAll ? filteredData.length : serverTotalCount;
const effectivePageCount = fetchAll
- ? Math.ceil(filteredData.length / pageSize)
+ ? Math.ceil(filteredData.length / effectivePageSize)
: serverPageCount;
return (
-
+
{
searchParams.set("page", (page + 1).toString());
@@ -72,6 +82,7 @@ export default function QueryPageTable(props: Props) {
gotoPage(page);
}}
+ onPageSizeChange={setLocalPageSize}
>
);
diff --git a/frontend/src/components/toolbox/Toolbox.module.scss b/frontend/src/components/toolbox/Toolbox.module.scss
index 5bdd3823e2..105dc2bd97 100644
--- a/frontend/src/components/toolbox/Toolbox.module.scss
+++ b/frontend/src/components/toolbox/Toolbox.module.scss
@@ -1,9 +1,20 @@
.group {
+ border-radius: var(--bz-radius-lg);
+ margin: 8px 12px;
+
@include mantine.light {
- background-color: var(--mantine-color-gray-3);
+ background-color: var(--mantine-color-gray-1);
+ border: 1px solid var(--bz-border-card);
}
@include mantine.dark {
- background-color: var(--mantine-color-dark-5);
+ background-color: var(--bz-surface-card);
+ border: 1px solid var(--bz-border-card);
+ }
+
+ @include mantine.smaller-than(sm) {
+ overflow-x: auto;
+ flex-wrap: nowrap !important;
+ scrollbar-width: none;
}
}
diff --git a/frontend/src/constants/batch.ts b/frontend/src/constants/batch.ts
new file mode 100644
index 0000000000..612c97c376
--- /dev/null
+++ b/frontend/src/constants/batch.ts
@@ -0,0 +1,10 @@
+import { BatchAction } from "@/apis/raw/subtitles";
+
+export const SUBTITLE_TOOL_ACTIONS: [BatchAction, string][] = [
+ ["OCR_fixes", "OCR Fixes"],
+ ["common", "Common Fixes"],
+ ["remove_HI", "Remove Hearing Impaired"],
+ ["remove_tags", "Remove Style Tags"],
+ ["fix_uppercase", "Fix Uppercase"],
+ ["reverse_rtl", "Reverse RTL"],
+];
diff --git a/frontend/src/modules/modals/ModalsProvider.tsx b/frontend/src/modules/modals/ModalsProvider.tsx
index 65c9878480..f87389b037 100644
--- a/frontend/src/modules/modals/ModalsProvider.tsx
+++ b/frontend/src/modules/modals/ModalsProvider.tsx
@@ -8,9 +8,20 @@ import { ModalComponent, StaticModals } from "./WithModal";
const DefaultModalProps: MantineModalsProviderProps["modalProps"] = {
centered: true,
styles: {
- // modals: {
- // maxWidth: "100%",
- // },
+ content: {
+ background: "var(--bz-surface-overlay)",
+ border: "1px solid var(--bz-border-card)",
+ borderRadius: "var(--bz-radius-lg)",
+ },
+ header: {
+ background: "transparent",
+ },
+ body: {
+ background: "transparent",
+ },
+ overlay: {
+ background: "rgba(0, 0, 0, 0.6)",
+ },
},
};
diff --git a/frontend/src/modules/modals/WithModal.tsx b/frontend/src/modules/modals/WithModal.tsx
index de7b79155a..252034f113 100644
--- a/frontend/src/modules/modals/WithModal.tsx
+++ b/frontend/src/modules/modals/WithModal.tsx
@@ -1,5 +1,3 @@
-/* eslint-disable @typescript-eslint/ban-types */
-
import { createContext, FunctionComponent } from "react";
import { ContextModalProps } from "@mantine/modals";
import { ModalSettings } from "@mantine/modals/lib/context";
diff --git a/frontend/src/modules/modals/hooks.ts b/frontend/src/modules/modals/hooks.ts
index 667e429d32..0dd5c2ccb8 100644
--- a/frontend/src/modules/modals/hooks.ts
+++ b/frontend/src/modules/modals/hooks.ts
@@ -1,4 +1,3 @@
-/* eslint-disable @typescript-eslint/ban-types */
import { useCallback, useContext, useMemo } from "react";
import { useModals as useMantineModals } from "@mantine/modals";
import { ModalSettings } from "@mantine/modals/lib/context";
diff --git a/frontend/src/modules/socketio/reducer.ts b/frontend/src/modules/socketio/reducer.ts
index 30e94d5f5d..78bc48b96a 100644
--- a/frontend/src/modules/socketio/reducer.ts
+++ b/frontend/src/modules/socketio/reducer.ts
@@ -41,6 +41,10 @@ export function createDefaultReducer(): SocketIO.Reducer[] {
queryKey: [QueryKeys.Series, id],
});
});
+ // Invalidate series list so Missing Subtitles column refreshes
+ void queryClient.invalidateQueries({
+ queryKey: [QueryKeys.Series],
+ });
},
delete: (ids) => {
LOG("info", "Invalidating series", ids);
@@ -60,6 +64,10 @@ export function createDefaultReducer(): SocketIO.Reducer[] {
queryKey: [QueryKeys.Movies, id],
});
});
+ // Invalidate movies list so Missing Subtitles column refreshes
+ void queryClient.invalidateQueries({
+ queryKey: [QueryKeys.Movies],
+ });
},
delete: (ids) => {
LOG("info", "Invalidating movies", ids);
diff --git a/frontend/src/pages/Authentication.tsx b/frontend/src/pages/Authentication.tsx
index 7a164c6c45..3c9717b01e 100644
--- a/frontend/src/pages/Authentication.tsx
+++ b/frontend/src/pages/Authentication.tsx
@@ -30,7 +30,7 @@ const Authentication: FunctionComponent = () => {
{configData && (
-
+
@@ -184,7 +178,7 @@ const AutopulseSelector: FunctionComponent = (
-
+
Server:
{" "}
@@ -207,7 +201,7 @@ const AutopulseSelector: FunctionComponent = (
)}
{configData.template_info && (
-
+
{configData.template_info}
)}
diff --git a/frontend/src/pages/Settings/Plex/ConnectionsCard.module.scss b/frontend/src/pages/Settings/Plex/ConnectionsCard.module.scss
index 3bc9cf7d15..3dd2c04c31 100644
--- a/frontend/src/pages/Settings/Plex/ConnectionsCard.module.scss
+++ b/frontend/src/pages/Settings/Plex/ConnectionsCard.module.scss
@@ -1,17 +1,13 @@
.connectionIndicator {
&.success {
- color: var(--mantine-color-green-6);
+ color: var(--bz-stat-completed);
}
&.error {
- color: var(--mantine-color-red-6);
+ color: var(--bz-stat-failed);
}
}
.serverConnectionCard {
- background: var(--mantine-color-gray-0);
-
- [data-mantine-color-scheme="dark"] & {
- background: var(--mantine-color-dark-6);
- }
+ background: var(--bz-surface-card);
}
diff --git a/frontend/src/pages/Settings/Plex/ConnectionsCard.tsx b/frontend/src/pages/Settings/Plex/ConnectionsCard.tsx
index a3ba46848b..fccae1b5f5 100644
--- a/frontend/src/pages/Settings/Plex/ConnectionsCard.tsx
+++ b/frontend/src/pages/Settings/Plex/ConnectionsCard.tsx
@@ -18,7 +18,7 @@ const ConnectionsCard: FC = ({
if (!server) return null;
return (
-
+
Verified Connections:
diff --git a/frontend/src/pages/Settings/Plex/ServerSection.tsx b/frontend/src/pages/Settings/Plex/ServerSection.tsx
index 108bad3b54..4aac02a50b 100644
--- a/frontend/src/pages/Settings/Plex/ServerSection.tsx
+++ b/frontend/src/pages/Settings/Plex/ServerSection.tsx
@@ -1,4 +1,4 @@
-import { useState } from "react";
+import { useEffect, useState } from "react";
import {
ActionIcon,
Alert,
@@ -52,14 +52,16 @@ const ServerSection = () => {
);
// Reset state when authentication changes from false to true (re-authentication)
- if (isAuthenticated && !wasAuthenticated) {
- setSelectedServer(null);
- setIsSelecting(false);
- setIsSaved(false);
- setWasAuthenticated(true);
- } else if (!isAuthenticated && wasAuthenticated) {
- setWasAuthenticated(false);
- }
+ useEffect(() => {
+ if (isAuthenticated && !wasAuthenticated) {
+ setSelectedServer(null);
+ setIsSelecting(false);
+ setIsSaved(false);
+ setWasAuthenticated(true);
+ } else if (!isAuthenticated && wasAuthenticated) {
+ setWasAuthenticated(false);
+ }
+ }, [isAuthenticated, wasAuthenticated]);
// Consolidated server selection and saving logic
const selectAndSaveServer = async (server: Plex.Server) => {
@@ -124,15 +126,24 @@ const ServerSection = () => {
};
// Run initialization when data is available
- if (isAuthenticated && (savedSelectedServer || servers.length > 0)) {
- handleInitialization();
- }
+ useEffect(() => {
+ if (isAuthenticated && (savedSelectedServer || servers.length > 0)) {
+ handleInitialization();
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [
+ isAuthenticated,
+ savedSelectedServer,
+ servers.length,
+ selectedServer,
+ isSaved,
+ ]);
if (!isAuthenticated) {
return null;
}
return (
-
+
Plex Servers
diff --git a/frontend/src/pages/Settings/Plex/WebhookSelector.tsx b/frontend/src/pages/Settings/Plex/WebhookSelector.tsx
index 7be6736d60..c9775d222d 100644
--- a/frontend/src/pages/Settings/Plex/WebhookSelector.tsx
+++ b/frontend/src/pages/Settings/Plex/WebhookSelector.tsx
@@ -158,7 +158,7 @@ const WebhookSelector: FunctionComponent = (props) => {
{label}
{description && (
-
+
{description}
)}
@@ -209,7 +209,7 @@ const WebhookSelector: FunctionComponent = (props) => {
{label}
-
+
{description ||
"Create or remove webhooks in Plex to trigger subtitle searches. In this list you can find your current webhooks."}
diff --git a/frontend/src/pages/Settings/Plex/index.tsx b/frontend/src/pages/Settings/Plex/index.tsx
index bbb0536982..8c38b285f6 100644
--- a/frontend/src/pages/Settings/Plex/index.tsx
+++ b/frontend/src/pages/Settings/Plex/index.tsx
@@ -20,7 +20,7 @@ const SettingsPlexView = () => {
-
+
diff --git a/frontend/src/pages/Settings/Providers/components.tsx b/frontend/src/pages/Settings/Providers/components.tsx
index 67e478e961..cecdc0b283 100644
--- a/frontend/src/pages/Settings/Providers/components.tsx
+++ b/frontend/src/pages/Settings/Providers/components.tsx
@@ -171,7 +171,7 @@ export const ProviderView: FunctionComponent = ({
{v.name ?? capitalize(v.key)}
-
+
Priority: {priority}
@@ -276,7 +276,7 @@ const ProviderTool: FunctionComponent = ({
resolveProviderPriorities(staged, settings)[info.key] ?? 100;
form.setFieldValue(`settings.${priorityKey}`, priorityValue);
}
- }, [info?.key]);
+ }, [info?.key]); // eslint-disable-line react-hooks/exhaustive-deps
const deletePayload = useCallback(() => {
if (payload && enabledProviders) {
@@ -463,7 +463,7 @@ const ProviderTool: FunctionComponent = ({
});
return {elements} ;
- }, [info, form, form.values.settings]);
+ }, [info, form, form.values.settings]); // eslint-disable-line react-hooks/exhaustive-deps
return (
@@ -498,7 +498,10 @@ const ProviderTool: FunctionComponent = ({
- Disable
+ Delete
+
+ modals.closeAll()}>
+ Cancel
= ({
submit(form.values);
}}
>
- Enable
+ Save
diff --git a/frontend/src/pages/Settings/Providers/index.tsx b/frontend/src/pages/Settings/Providers/index.tsx
index a0f76ecaa1..49b0e666b0 100644
--- a/frontend/src/pages/Settings/Providers/index.tsx
+++ b/frontend/src/pages/Settings/Providers/index.tsx
@@ -1,6 +1,7 @@
import { FunctionComponent } from "react";
import { Anchor } from "@mantine/core";
import {
+ Check,
CollapseBox,
Layout,
Message,
@@ -17,6 +18,15 @@ const SettingsProvidersView: FunctionComponent = () => {
return (
+
+
+ Query providers in priority order and stop when a subtitle meeting the
+ minimum score is found. When disabled, all providers are queried
+ simultaneously and the best result is selected.
+
= [
key: "opensubtitles",
name: "OpenSubtitles.org",
description:
- "Choose between Official API (requires VIP) or Web Scraper (no VIP needed)",
+ "Uses web scraper (OpenSubtitles.org login is no longer available). Get it at https://github.com/LavX/opensubtitles-scraper",
inputs: [
- {
- type: "switch",
- key: "use_web_scraper",
- name: "Use Web Scraper (bypasses VIP requirement)",
- defaultValue: false,
- },
- // Web Scraper specific fields - only show when web scraper is enabled
{
type: "text",
key: "scraper_service_url",
name: "Scraper Service URL",
defaultValue: "http://localhost:8000",
description: "URL of the OpenSubtitles scraper service",
- condition: {
- key: "use_web_scraper",
- value: true,
- },
- },
- // API specific fields - only show when web scraper is disabled
- {
- type: "text",
- key: "username",
- name: "Username",
- description: "OpenSubtitles.org username",
- condition: {
- key: "use_web_scraper",
- value: false,
- },
- },
- {
- type: "password",
- key: "password",
- name: "Password",
- description: "OpenSubtitles.org password",
- condition: {
- key: "use_web_scraper",
- value: false,
- },
- },
- {
- type: "switch",
- key: "vip",
- name: "VIP Status",
- description: "Enable if you have VIP subscription",
- condition: {
- key: "use_web_scraper",
- value: false,
- },
- },
- {
- type: "switch",
- key: "ssl",
- name: "Use SSL",
- description: "Use secure connection",
- condition: {
- key: "use_web_scraper",
- value: false,
- },
},
- // Common fields - always show
{
type: "switch",
key: "skip_wrong_fps",
diff --git a/frontend/src/pages/Settings/Subtitles/index.tsx b/frontend/src/pages/Settings/Subtitles/index.tsx
index 31856888d9..e00cf56af4 100644
--- a/frontend/src/pages/Settings/Subtitles/index.tsx
+++ b/frontend/src/pages/Settings/Subtitles/index.tsx
@@ -1,17 +1,14 @@
-import React, { FunctionComponent, useMemo } from "react";
-import {
- Code,
- Divider,
- Space,
- Table,
- Text as MantineText,
-} from "@mantine/core";
+import React, { FunctionComponent } from "react";
+import { Link } from "react-router";
+import { Code, Space, Table, Text as MantineText } from "@mantine/core";
import {
Check,
+ Chips,
CollapseBox,
Layout,
Message,
MultiSelector,
+ Number,
Section,
Selector,
Slider,
@@ -21,22 +18,15 @@ import {
SubzeroColorModification,
SubzeroModification,
} from "@/pages/Settings/utilities/modifications";
-import { TranslatorStatusPanelWithFormContext } from "@/components/TranslatorStatus";
-import { useTranslatorModels } from "@/apis/hooks/translator";
-import { SelectorOption } from "@/components";
import {
adaptiveSearchingDelayOption,
adaptiveSearchingDeltaOption,
- aiTranslatorConcurrentOptions,
- aiTranslatorModelOptions,
- aiTranslatorReasoningOptions,
colorOptions,
embeddedSubtitlesParserOption,
folderOptions,
hiExtensionOptions,
providerOptions,
syncMaxOffsetSecondsOptions,
- translatorOption,
} from "./options";
interface CommandOption {
@@ -130,45 +120,15 @@ const commandOptions: CommandOption[] = [
const commandOptionElements: React.JSX.Element[] = commandOptions.map(
(op, idx) => (
-
-
+
+
{op.option}
-
- {op.description}
-
+
+ {op.description}
+
),
);
-/**
- * AI Model Selector that fetches available models from the translator service.
- * Falls back to static options if the service is unavailable.
- */
-const AIModelSelector: FunctionComponent = () => {
- const { data: modelsResponse, isLoading, isError } = useTranslatorModels();
-
- const modelOptions = useMemo((): SelectorOption[] => {
- // If we have data from the service, use it
- if (modelsResponse?.models && modelsResponse.models.length > 0) {
- return modelsResponse.models.map((model) => ({
- label: model.name + (model.is_default ? " (Recommended)" : ""),
- value: model.id,
- }));
- }
- // Fall back to static options
- return aiTranslatorModelOptions;
- }, [modelsResponse]);
-
- return (
-
- );
-};
-
const SettingsSubtitlesView: FunctionComponent = () => {
return (
@@ -566,146 +526,15 @@ const SettingsSubtitlesView: FunctionComponent = () => {
settingKey="settings-general-postprocessing_cmd"
>
- {commandOptionElements}
+ {commandOptionElements}
-
-
- val === "gemini"}
- >
-
-
-
- You can generate it here: https://aistudio.google.com/apikey
-
-
- val === "lingarr"}
- >
-
- Base URL of Lingarr (e.g., http://localhost:9876)
-
-
- Optional API key for authentication. Leave empty if your Lingarr
- instance doesn't require authentication.
-
-
- val === "openrouter"}
- >
-
-
- URL of the AI Subtitle Translator service.
-
-
- https://github.com/LavX/ai-subtitle-translator
-
-
-
-
- Get your API key at{" "}
-
- https://openrouter.ai/keys
-
-
-
-
- Models are fetched from the AI Subtitle Translator service. You can
- also type any model ID from{" "}
-
- https://openrouter.ai/models
- {" "}
- in the field above.
-
-
-
- Lower = more deterministic, higher = more creative. Default: 0.3
-
-
-
- Maximum number of translations to process simultaneously. Higher
- values use more API quota.
-
-
-
- Enable extended thinking for supported models (Gemini, Claude Haiku
- 4.5, Grok). Higher levels improve translation quality but increase
- cost and time.
-
-
-
-
-
+
+ Translator configuration has moved to its own page.{" "}
+ Go to AI Translator โ
+
);
diff --git a/frontend/src/pages/Settings/Subtitles/options.ts b/frontend/src/pages/Settings/Subtitles/options.ts
index d879e595ad..b14d88f44e 100644
--- a/frontend/src/pages/Settings/Subtitles/options.ts
+++ b/frontend/src/pages/Settings/Subtitles/options.ts
@@ -182,72 +182,3 @@ export const syncMaxOffsetSecondsOptions: SelectorOption[] = [
value: 600,
},
];
-
-export const translatorOption: SelectorOption[] = [
- {
- label: "Google Translate",
- value: "google_translate",
- },
- {
- label: "Gemini",
- value: "gemini",
- },
- {
- label: "Lingarr",
- value: "lingarr",
- },
- {
- label: "AI Subtitle Translator",
- value: "openrouter",
- },
-];
-
-export const aiTranslatorModelOptions: SelectorOption[] = [
- {
- label: "Gemini 2.5 Flash (Recommended)",
- value: "google/gemini-2.5-flash-preview-05-20",
- },
- {
- label: "Gemini 2.5 Flash Lite (Fast & Cheap)",
- value: "google/gemini-2.5-flash-lite-preview-06-17",
- },
- {
- label: "GPT-4o Mini",
- value: "openai/gpt-4o-mini",
- },
- {
- label: "Claude 3 Haiku",
- value: "anthropic/claude-3-haiku",
- },
- {
- label: "Claude Haiku 4.5 (Extended Thinking)",
- value: "anthropic/claude-haiku-4.5",
- },
- {
- label: "LLaMA 4 Maverick",
- value: "meta-llama/llama-4-maverick",
- },
- {
- label: "Grok 4.1 Fast",
- value: "x-ai/grok-4.1-fast",
- },
- {
- label: "Kimi K2",
- value: "moonshotai/kimi-k2-0905",
- },
-];
-
-export const aiTranslatorReasoningOptions: SelectorOption[] = [
- { label: "Disabled", value: "disabled" },
- { label: "Low (Minimal thinking)", value: "low" },
- { label: "Medium (Default)", value: "medium" },
- { label: "High (Extended thinking)", value: "high" },
-];
-
-export const aiTranslatorConcurrentOptions: SelectorOption[] = [
- { label: "1 (Low)", value: 1 },
- { label: "2 (Default)", value: 2 },
- { label: "3", value: 3 },
- { label: "4", value: 4 },
- { label: "5 (High)", value: 5 },
-];
diff --git a/frontend/src/pages/Settings/Translator/AIModelSelector.tsx b/frontend/src/pages/Settings/Translator/AIModelSelector.tsx
new file mode 100644
index 0000000000..5720e82a8e
--- /dev/null
+++ b/frontend/src/pages/Settings/Translator/AIModelSelector.tsx
@@ -0,0 +1,25 @@
+import { FunctionComponent } from "react";
+import { Autocomplete } from "@mantine/core";
+import { useBaseInput } from "@/pages/Settings/utilities/hooks";
+import { aiTranslatorModelOptions } from "./options";
+
+const modelData = aiTranslatorModelOptions.map((o) => o.value);
+
+const AIModelSelector: FunctionComponent = () => {
+ const { value, update } = useBaseInput<{ settingKey: string }, string>({
+ settingKey: "settings-translator-openrouter_model",
+ });
+
+ return (
+ update(val)}
+ placeholder="Select or type any model ID..."
+ limit={30}
+ />
+ );
+};
+
+export default AIModelSelector;
diff --git a/frontend/src/pages/Settings/Translator/ModelDetails.tsx b/frontend/src/pages/Settings/Translator/ModelDetails.tsx
new file mode 100644
index 0000000000..fb5224addb
--- /dev/null
+++ b/frontend/src/pages/Settings/Translator/ModelDetails.tsx
@@ -0,0 +1,246 @@
+import { FunctionComponent } from "react";
+import { Badge, Box, Group, SimpleGrid, Text } from "@mantine/core";
+import { useQuery } from "@tanstack/react-query";
+
+// Average total token usage per translation (calibrated from real data)
+// Episode ~90K tokens, Movie ~180K tokens
+// With caching: ~70% of input tokens hit cache on subsequent episodes (same series = same system prompt)
+const AVG_EPISODE_TOKENS = 90_000;
+const AVG_MOVIE_TOKENS = 180_000;
+const INPUT_RATIO = 0.4;
+const OUTPUT_RATIO = 0.6;
+const CACHE_HIT_RATIO = 0.7; // 70% of input tokens from cache on repeat runs
+
+// Reasoning adds extra output tokens (thinking). Multiplier on output token count.
+const REASONING_OUTPUT_MULTIPLIER: Record = {
+ disabled: 1,
+ low: 1.3,
+ medium: 1.8,
+ high: 2.5,
+};
+
+interface OpenRouterModel {
+ id: string;
+ name: string;
+ context_length: number;
+ pricing: {
+ prompt: string;
+ completion: string;
+ cache_read?: string;
+ cache_write?: string;
+ };
+ architecture?: {
+ modality: string;
+ };
+ top_provider?: {
+ max_completion_tokens?: number;
+ };
+ supported_parameters?: string[];
+}
+
+function useOpenRouterModelDetails(modelId: string) {
+ return useQuery({
+ queryKey: ["openrouter", "models", modelId],
+ queryFn: async () => {
+ const response = await fetch("https://openrouter.ai/api/v1/models");
+ const data = await response.json();
+ const found = data.data?.find((m: OpenRouterModel) => m.id === modelId);
+ return (found as OpenRouterModel) || null;
+ },
+ enabled: !!modelId,
+ staleTime: 5 * 60 * 1000,
+ retry: false,
+ });
+}
+
+function calcCost(
+ totalTokens: number,
+ promptCost: number,
+ completionCost: number,
+ cacheReadCost: number | null,
+ reasoningMultiplier: number,
+) {
+ const inputTokens = totalTokens * INPUT_RATIO;
+ const outputTokens = totalTokens * OUTPUT_RATIO * reasoningMultiplier;
+
+ // Standard cost (no caching)
+ const standard = inputTokens * promptCost + outputTokens * completionCost;
+
+ // Cached cost (subsequent episodes reuse system prompt)
+ if (cacheReadCost !== null) {
+ const cachedInput = inputTokens * CACHE_HIT_RATIO * cacheReadCost;
+ const uncachedInput = inputTokens * (1 - CACHE_HIT_RATIO) * promptCost;
+ const cached = cachedInput + uncachedInput + outputTokens * completionCost;
+ return { standard, cached };
+ }
+
+ return { standard, cached: null };
+}
+
+const formatCost = (cost: number) => {
+ if (cost === 0) return "Free";
+ if (cost < 0.001) return `$${(cost * 1000).toFixed(4)}/1K`;
+ return `$${cost.toFixed(4)}`;
+};
+
+const formatPerMillion = (perToken: number) => {
+ if (perToken === 0) return "Free";
+ return `$${(perToken * 1_000_000).toFixed(2)}/M`;
+};
+
+interface ModelDetailsProps {
+ modelId: string;
+ reasoningLevel?: string;
+}
+
+const ModelDetailsCard: FunctionComponent = ({
+ modelId,
+ reasoningLevel = "disabled",
+}) => {
+ const { data: model, isLoading: loading } =
+ useOpenRouterModelDetails(modelId);
+
+ if (loading) {
+ return (
+
+ Loading model details...
+
+ );
+ }
+
+ if (!model) {
+ return (
+
+ Model details unavailable for {modelId}
+
+ );
+ }
+
+ const promptCost = parseFloat(model.pricing.prompt);
+ const completionCost = parseFloat(model.pricing.completion);
+ const cacheReadCost = model.pricing.cache_read
+ ? parseFloat(model.pricing.cache_read)
+ : null;
+ const cacheWriteCost = model.pricing.cache_write
+ ? parseFloat(model.pricing.cache_write)
+ : null;
+ const hasCache = cacheReadCost !== null || cacheWriteCost !== null;
+
+ const supportsReasoning =
+ model.supported_parameters?.includes("reasoning") ?? false;
+ const effectiveReasoning = supportsReasoning ? reasoningLevel : "disabled";
+ const reasoningMul = REASONING_OUTPUT_MULTIPLIER[effectiveReasoning] ?? 1;
+
+ const episode = calcCost(
+ AVG_EPISODE_TOKENS,
+ promptCost,
+ completionCost,
+ cacheReadCost,
+ reasoningMul,
+ );
+ const movie = calcCost(
+ AVG_MOVIE_TOKENS,
+ promptCost,
+ completionCost,
+ cacheReadCost,
+ reasoningMul,
+ );
+
+ return (
+
+
+ {model.name}
+
+ {/* Per-million pricing โ always show all available prices */}
+
+
+
+ Input
+
+
+ {formatPerMillion(promptCost)}
+
+
+
+
+ Output
+
+
+ {formatPerMillion(completionCost)}
+
+
+ {cacheReadCost !== null && (
+
+
+ Cache Read
+
+
+ {formatPerMillion(cacheReadCost)}
+
+
+ )}
+ {cacheWriteCost !== null && (
+
+
+ Cache Write
+
+
+ {formatPerMillion(cacheWriteCost)}
+
+
+ )}
+
+ {/* Estimations โ single best estimate per type */}
+
+
+
+ Est. / Episode
+
+
+ {formatCost(episode.cached ?? episode.standard)}
+
+
+
+
+ Est. / Movie
+
+
+ {formatCost(movie.cached ?? movie.standard)}
+
+
+
+
+
+ Context: {(model.context_length / 1024).toFixed(0)}K tokens
+
+ {model.top_provider?.max_completion_tokens && (
+
+ Max output:{" "}
+ {(model.top_provider.max_completion_tokens / 1024).toFixed(0)}K
+ tokens
+
+ )}
+ {model.supported_parameters?.includes("reasoning") && (
+
+ Reasoning
+
+ )}
+ {hasCache && (
+
+ Prompt caching
+
+ )}
+
+
+ );
+};
+
+export { useOpenRouterModelDetails };
+export default ModelDetailsCard;
diff --git a/frontend/src/pages/Settings/Translator/Translator.test.tsx b/frontend/src/pages/Settings/Translator/Translator.test.tsx
new file mode 100644
index 0000000000..776a098662
--- /dev/null
+++ b/frontend/src/pages/Settings/Translator/Translator.test.tsx
@@ -0,0 +1,48 @@
+import { describe, expect, it } from "vitest";
+import {
+ aiTranslatorConcurrentOptions,
+ aiTranslatorModelOptions,
+ aiTranslatorReasoningOptions,
+ translatorOption,
+} from "./options";
+
+describe("Translator options", () => {
+ it("exports all required option arrays", () => {
+ expect(translatorOption).toBeDefined();
+ expect(translatorOption.length).toBeGreaterThan(0);
+ expect(
+ translatorOption.find((o) => o.value === "openrouter"),
+ ).toBeDefined();
+
+ expect(aiTranslatorModelOptions).toBeDefined();
+ expect(aiTranslatorModelOptions.length).toBeGreaterThan(0);
+
+ expect(aiTranslatorReasoningOptions).toBeDefined();
+ expect(aiTranslatorReasoningOptions).toContainEqual({
+ label: "Disabled",
+ value: "disabled",
+ });
+
+ expect(aiTranslatorConcurrentOptions).toBeDefined();
+ expect(aiTranslatorConcurrentOptions.length).toBe(5);
+ });
+
+ it("includes all 4 translator engines", () => {
+ const values = translatorOption.map((o) => o.value);
+ expect(values).toContain("google_translate");
+ expect(values).toContain("gemini");
+ expect(values).toContain("lingarr");
+ expect(values).toContain("openrouter");
+ });
+
+ it("concurrent options range from 1 to 5", () => {
+ const values = aiTranslatorConcurrentOptions.map((o) => o.value);
+ expect(values).toEqual([1, 2, 3, 4, 5]);
+ });
+
+ it("reasoning options include disabled and high", () => {
+ const values = aiTranslatorReasoningOptions.map((o) => o.value);
+ expect(values).toContain("disabled");
+ expect(values).toContain("high");
+ });
+});
diff --git a/frontend/src/pages/Settings/Translator/index.tsx b/frontend/src/pages/Settings/Translator/index.tsx
new file mode 100644
index 0000000000..6a06af62b4
--- /dev/null
+++ b/frontend/src/pages/Settings/Translator/index.tsx
@@ -0,0 +1,518 @@
+import { FunctionComponent, useEffect } from "react";
+import {
+ Alert,
+ Anchor,
+ Badge,
+ Button,
+ Group,
+ Paper,
+ SimpleGrid,
+ Stack,
+ Text as MantineText,
+ Tooltip,
+ UnstyledButton,
+} from "@mantine/core";
+import { notifications } from "@mantine/notifications";
+import {
+ faCircleInfo,
+ faExclamationTriangle,
+} from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { useTestTranslator } from "@/apis/hooks/translator";
+import { TranslatorStatusPanelWithFormContext } from "@/components/TranslatorStatus";
+import {
+ Check,
+ Chips,
+ CollapseBox,
+ Layout,
+ Message,
+ Number,
+ Password,
+ Section,
+ Selector,
+ Slider,
+ Text,
+} from "@/pages/Settings/components";
+import { useFormActions } from "@/pages/Settings/utilities/FormValues";
+import { useSettingValue } from "@/pages/Settings/utilities/hooks";
+import AIModelSelector from "./AIModelSelector";
+import ModelDetailsCard, { useOpenRouterModelDetails } from "./ModelDetails";
+import {
+ aiTranslatorConcurrentOptions,
+ aiTranslatorParallelBatchesOptions,
+ aiTranslatorReasoningOptions,
+} from "./options";
+
+const engineOptions = [
+ { value: "openrouter", label: "OpenRouter" },
+ { value: "google_translate", label: "Google Translate" },
+ { value: "gemini", label: "Gemini" },
+ { value: "lingarr", label: "Lingarr" },
+];
+
+const TranslatorEnginePicker: FunctionComponent = () => {
+ const current = useSettingValue(
+ "settings-translator-translator_type",
+ );
+ const { setValue } = useFormActions();
+
+ return (
+
+ {engineOptions.map((opt) => {
+ const active = current === opt.value;
+ return (
+
+ setValue(
+ active ? null : opt.value,
+ "settings-translator-translator_type",
+ )
+ }
+ >
+
+ {opt.label}
+
+
+ );
+ })}
+
+ );
+};
+
+const FreeModelWarning: FunctionComponent = () => {
+ const modelId = useSettingValue(
+ "settings-translator-openrouter_model",
+ );
+ if (!modelId) return null;
+ const isFree =
+ modelId === "openrouter/free" ||
+ modelId.endsWith(":free") ||
+ modelId.includes("/free");
+ if (!isFree) return null;
+
+ return (
+ }
+ p="xs"
+ >
+
+ Free models are heavily rate-limited by their upstream providers. Expect
+ slow translations, frequent retries, and possible job failures. Use a
+ paid model for reliable results.
+
+
+ );
+};
+
+const ReasoningSelector: FunctionComponent = () => {
+ const modelId = useSettingValue(
+ "settings-translator-openrouter_model",
+ );
+ const { data: model, isLoading } = useOpenRouterModelDetails(modelId ?? "");
+ const modelLoaded = !!model && !isLoading;
+ const supportsReasoning =
+ model?.supported_parameters?.includes("reasoning") ?? false;
+ const { setValue } = useFormActions();
+
+ // Only auto-disable after model data has loaded, not while loading
+ const currentReasoning = useSettingValue(
+ "settings-translator-openrouter_reasoning",
+ );
+ useEffect(() => {
+ if (
+ modelLoaded &&
+ !supportsReasoning &&
+ currentReasoning &&
+ currentReasoning !== "disabled"
+ ) {
+ setValue("disabled", "settings-translator-openrouter_reasoning");
+ }
+ }, [modelLoaded, supportsReasoning, currentReasoning, setValue]);
+
+ return (
+
+ );
+};
+
+const ModelDetailsFromSetting: FunctionComponent = () => {
+ const modelId = useSettingValue(
+ "settings-translator-openrouter_model",
+ );
+ const reasoningLevel = useSettingValue(
+ "settings-translator-openrouter_reasoning",
+ );
+ if (!modelId) return null;
+ return (
+
+ );
+};
+
+const TestConnectionButton: FunctionComponent = () => {
+ const testMutation = useTestTranslator();
+ const serviceUrl = useSettingValue(
+ "settings-translator-openrouter_url",
+ );
+ const apiKey = useSettingValue(
+ "settings-translator-openrouter_api_key",
+ );
+ const encryptionKey = useSettingValue(
+ "settings-translator-openrouter_encryption_key",
+ );
+
+ const handleTest = () => {
+ testMutation.mutate(
+ {
+ serviceUrl: serviceUrl ?? undefined,
+ apiKey: apiKey ?? undefined,
+ encryptionKey: encryptionKey ?? undefined,
+ },
+ {
+ onSuccess: (data) => {
+ if (data.error) {
+ notifications.show({
+ title: "Connection Failed",
+ message: data.error,
+ color: "red",
+ });
+ return;
+ }
+ if (data.encryption) {
+ const encOk = data.encryption.status === "ok";
+ notifications.show({
+ title: encOk ? "Encryption" : "Encryption Failed",
+ message: data.encryption.message,
+ color: encOk ? "green" : "red",
+ });
+ }
+ if (data.apiKey) {
+ const keyOk = data.apiKey.status === "ok";
+ notifications.show({
+ title: keyOk ? "API Key" : "API Key Failed",
+ message: keyOk
+ ? `${data.apiKey.label}${data.apiKey.isFreeTier ? " (Free tier)" : ""}`
+ : "API key validation failed",
+ color: keyOk ? "green" : "red",
+ });
+ }
+ if (!data.encryption && !data.apiKey) {
+ notifications.show({
+ title: "Connected",
+ message: "Service reachable",
+ color: "green",
+ });
+ }
+ },
+ onError: () => {
+ notifications.show({
+ title: "Connection Failed",
+ message: "Could not reach the translator service",
+ color: "red",
+ });
+ },
+ },
+ );
+ };
+
+ return (
+
+ Test Connection
+
+ );
+};
+
+const SettingsTranslatorView: FunctionComponent = () => {
+ return (
+
+ {/* Zone 1: Translator Engine */}
+
+
+
+ Translator Engine
+
+
+
+
+
+
+ Score
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Gemini config โ unchanged */}
+ val === "gemini"}
+ >
+
+
+
+
+ Number of subtitle lines sent in each Gemini request. Higher values
+ reduce the number of API calls and can speed up translation, but may
+ increase timeout or response-size errors. Start with 300 (default),
+ then lower it if requests fail or raise it gradually if your model
+ handles larger batches reliably.
+
+ {
+ const uniqueKeys = new Set(
+ (values ?? []).map((value) => value.trim()).filter(Boolean),
+ );
+ return Array.from(uniqueKeys);
+ }}
+ />
+
+ You can generate keys here: https://aistudio.google.com/apikey. Add
+ as many keys as needed; Bazarr rotates across available keys.
+
+
+
+
+ {/* Lingarr config โ unchanged */}
+ val === "lingarr"}
+ >
+
+
+ Base URL of Lingarr (e.g., http://localhost:9876)
+
+
+ Optional API key for authentication. Leave empty if your Lingarr
+ instance doesn't require authentication.
+
+
+
+
+ {/* AI Subtitle Translator โ Zones 2-4 */}
+ val === "openrouter"}
+ >
+
+ {/* Zone 2: Connection Card */}
+
+
+
+
+
+
+ Setup guide
+
+
+
+
+
+
+
+ Get your API key
+
+
+
+
+
+
+
+ How to get your key
+
+
+
+
+
+
+
+
+
+ {/* Zone 3: Model & Tuning Card */}
+
+
+
+
+
+ Models are fetched from the service. You can also type any model
+ ID from{" "}
+
+ openrouter.ai/models
+
+
+
+
+
+
+
+ deterministic โ โ creative
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Zone 4: Status & Jobs */}
+
+
+
+
+ );
+};
+
+export default SettingsTranslatorView;
diff --git a/frontend/src/pages/Settings/Translator/options.ts b/frontend/src/pages/Settings/Translator/options.ts
new file mode 100644
index 0000000000..a0bba7edbe
--- /dev/null
+++ b/frontend/src/pages/Settings/Translator/options.ts
@@ -0,0 +1,92 @@
+import { SelectorOption } from "@/components";
+
+export const translatorOption: SelectorOption[] = [
+ { label: "OpenRouter", value: "openrouter" },
+ { label: "Google Translate", value: "google_translate" },
+ { label: "Gemini", value: "gemini" },
+ { label: "Lingarr", value: "lingarr" },
+];
+
+export const aiTranslatorModelOptions: SelectorOption[] = [
+ { label: "anthropic/claude-haiku-4.5", value: "anthropic/claude-haiku-4.5" },
+ {
+ label: "anthropic/claude-sonnet-4.6",
+ value: "anthropic/claude-sonnet-4.6",
+ },
+ {
+ label: "bytedance-seed/seed-2.0-mini",
+ value: "bytedance-seed/seed-2.0-mini",
+ },
+ { label: "google/gemini-2.5-flash", value: "google/gemini-2.5-flash" },
+ {
+ label: "google/gemini-2.5-flash-lite-preview-09-2025",
+ value: "google/gemini-2.5-flash-lite-preview-09-2025",
+ },
+ { label: "google/gemini-2.5-pro", value: "google/gemini-2.5-pro" },
+ {
+ label: "google/gemini-3-flash-preview",
+ value: "google/gemini-3-flash-preview",
+ },
+ {
+ label: "google/gemini-3-pro-preview",
+ value: "google/gemini-3-pro-preview",
+ },
+ {
+ label: "google/gemini-3.1-flash-lite-preview",
+ value: "google/gemini-3.1-flash-lite-preview",
+ },
+ {
+ label: "google/gemini-3.1-pro-preview",
+ value: "google/gemini-3.1-pro-preview",
+ },
+ { label: "inception/mercury-2", value: "inception/mercury-2" },
+ {
+ label: "meta-llama/llama-4-maverick",
+ value: "meta-llama/llama-4-maverick",
+ },
+ { label: "meta-llama/llama-4-scout", value: "meta-llama/llama-4-scout" },
+ { label: "minimax/minimax-m2.7", value: "minimax/minimax-m2.7" },
+ {
+ label: "mistralai/mistral-small-2603",
+ value: "mistralai/mistral-small-2603",
+ },
+ { label: "moonshotai/kimi-k2.5", value: "moonshotai/kimi-k2.5" },
+ { label: "openai/gpt-4o-mini", value: "openai/gpt-4o-mini" },
+ { label: "openai/gpt-5", value: "openai/gpt-5" },
+ { label: "openai/gpt-5-mini", value: "openai/gpt-5-mini" },
+ { label: "openai/gpt-5-nano", value: "openai/gpt-5-nano" },
+ { label: "openai/gpt-5.4", value: "openai/gpt-5.4" },
+ { label: "openai/gpt-5.4-mini", value: "openai/gpt-5.4-mini" },
+ { label: "openai/gpt-5.4-nano", value: "openai/gpt-5.4-nano" },
+ { label: "openai/o4-mini", value: "openai/o4-mini" },
+ { label: "openrouter/auto", value: "openrouter/auto" },
+ { label: "openrouter/free", value: "openrouter/free" },
+ { label: "qwen/qwen3.5-plus-02-15", value: "qwen/qwen3.5-plus-02-15" },
+ { label: "x-ai/grok-4-fast", value: "x-ai/grok-4-fast" },
+ { label: "x-ai/grok-4.20-beta", value: "x-ai/grok-4.20-beta" },
+ { label: "z-ai/glm-4.7-flash", value: "z-ai/glm-4.7-flash" },
+];
+
+export const aiTranslatorReasoningOptions: SelectorOption[] = [
+ { label: "Disabled", value: "disabled" },
+ { label: "Low (Minimal thinking)", value: "low" },
+ { label: "Medium (Default)", value: "medium" },
+ { label: "High (Extended thinking)", value: "high" },
+];
+
+export const aiTranslatorConcurrentOptions: SelectorOption[] = [
+ { label: "1 (Low)", value: 1 },
+ { label: "2 (Default)", value: 2 },
+ { label: "3", value: 3 },
+ { label: "4", value: 4 },
+ { label: "5 (High)", value: 5 },
+];
+
+export const aiTranslatorParallelBatchesOptions: SelectorOption[] = [
+ { label: "1 (Sequential)", value: 1 },
+ { label: "2", value: 2 },
+ { label: "3", value: 3 },
+ { label: "4 (Default)", value: 4 },
+ { label: "6", value: 6 },
+ { label: "8 (Aggressive)", value: 8 },
+];
diff --git a/frontend/src/pages/Settings/components/Card.module.scss b/frontend/src/pages/Settings/components/Card.module.scss
index fbfa1c3d14..5202d72880 100644
--- a/frontend/src/pages/Settings/components/Card.module.scss
+++ b/frontend/src/pages/Settings/components/Card.module.scss
@@ -1,9 +1,15 @@
.card {
- border-radius: var(--mantine-radius-sm);
- border: 1px solid var(--mantine-color-gray-7);
+ border-radius: var(--bz-radius-lg);
+ border: 1px solid var(--bz-border-card);
+ background: var(--bz-surface-card);
+ transition:
+ background var(--bz-duration-normal) var(--bz-ease-standard),
+ border-color var(--bz-duration-normal) var(--bz-ease-standard),
+ box-shadow var(--bz-duration-normal) var(--bz-ease-standard);
&:hover {
- box-shadow: var(--mantine-shadow-md);
- border: 1px solid bazarr.$color-brand-5;
+ box-shadow: var(--bz-shadow-float);
+ border-color: var(--bz-border-hover);
+ background: var(--bz-surface-card-hover);
}
}
diff --git a/frontend/src/pages/Settings/components/Card.tsx b/frontend/src/pages/Settings/components/Card.tsx
index a8a33eec31..1040020931 100644
--- a/frontend/src/pages/Settings/components/Card.tsx
+++ b/frontend/src/pages/Settings/components/Card.tsx
@@ -1,4 +1,4 @@
-import { FunctionComponent } from "react";
+import React, { FunctionComponent } from "react";
import {
Center,
MantineStyleProp,
@@ -13,7 +13,7 @@ import styles from "./Card.module.scss";
interface CardProps {
description?: string;
- header?: string;
+ header?: React.ReactNode;
lineClamp?: number | undefined;
onClick?: () => void;
plus?: boolean;
diff --git a/frontend/src/pages/Settings/components/Layout.test.tsx b/frontend/src/pages/Settings/components/Layout.test.tsx
index 373a750c5e..dbbea9ea2a 100644
--- a/frontend/src/pages/Settings/components/Layout.test.tsx
+++ b/frontend/src/pages/Settings/components/Layout.test.tsx
@@ -1,5 +1,5 @@
import { Text } from "@mantine/core";
-import { describe, it } from "vitest";
+import { describe, expect, it } from "vitest";
import { customRender, screen } from "@/tests";
import Layout from "./Layout";
@@ -12,13 +12,29 @@ describe("Settings layout", () => {
);
});
- it.concurrent("save button should be disabled by default", () => {
+ it.concurrent(
+ "save button should not be visible when no changes are staged",
+ () => {
+ customRender(
+
+ Value
+ ,
+ );
+
+ // The floating save button is hidden when totalStagedCount === 0
+ expect(
+ screen.queryByRole("button", { name: /save/i }),
+ ).not.toBeInTheDocument();
+ },
+ );
+
+ it.concurrent("renders children content", () => {
customRender(
- Value
+ Test Content
,
);
- expect(screen.getAllByRole("button", { name: "Save" })[0]).toBeDisabled();
+ expect(screen.getByText("Test Content")).toBeInTheDocument();
});
});
diff --git a/frontend/src/pages/Settings/components/Layout.tsx b/frontend/src/pages/Settings/components/Layout.tsx
index ba74f25ece..7882333738 100644
--- a/frontend/src/pages/Settings/components/Layout.tsx
+++ b/frontend/src/pages/Settings/components/Layout.tsx
@@ -1,11 +1,25 @@
-import { FunctionComponent, ReactNode, useCallback, useMemo } from "react";
-import { Badge, Container, Group, LoadingOverlay } from "@mantine/core";
+import {
+ FunctionComponent,
+ ReactNode,
+ useCallback,
+ useEffect,
+ useMemo,
+} from "react";
+import {
+ Badge,
+ Box,
+ Button,
+ Container,
+ Group,
+ LoadingOverlay,
+ Transition,
+} from "@mantine/core";
import { useForm } from "@mantine/form";
-import { useDocumentTitle } from "@mantine/hooks";
-import { faSave } from "@fortawesome/free-solid-svg-icons";
+import { useDocumentTitle, useReducedMotion } from "@mantine/hooks";
+import { faFloppyDisk } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useSettingsMutation, useSystemSettings } from "@/apis/hooks";
import { useInstanceName } from "@/apis/hooks/site";
-import { Toolbox } from "@/components";
import { LoadingProvider } from "@/contexts";
import {
FormContext,
@@ -26,7 +40,8 @@ const Layout: FunctionComponent = (props) => {
const { children, name } = props;
const { data: settings, isLoading, isRefetching } = useSystemSettings();
- const { mutate, isPending: isMutating } = useSettingsMutation();
+ const { mutate, mutateAsync, isPending: isMutating } = useSettingsMutation();
+ const reducedMotion = useReducedMotion();
const form = useForm({
initialValues: {
@@ -44,7 +59,6 @@ const Layout: FunctionComponent = (props) => {
const submit = useCallback(
(values: FormValues) => {
const { settings, hooks } = values;
-
if (Object.keys(settings).length > 0) {
const settingsToSubmit = { ...settings };
runHooks(hooks, settingsToSubmit);
@@ -55,44 +69,117 @@ const Layout: FunctionComponent = (props) => {
[mutate],
);
+ const submitAndLeave = useCallback(async () => {
+ const { settings, hooks } = form.values;
+ if (Object.keys(settings).length > 0) {
+ const settingsToSubmit = { ...settings };
+ runHooks(hooks, settingsToSubmit);
+ LOG("info", "save & leave", settingsToSubmit);
+ await mutateAsync(settingsToSubmit);
+ }
+ }, [form.values, mutateAsync]);
+
const totalStagedCount = useMemo(() => {
return Object.keys(form.values.settings).length;
}, [form.values.settings]);
usePrompt(
totalStagedCount > 0,
- `You have ${totalStagedCount} unsaved changes, are you sure you want to leave?`,
+ `You have ${totalStagedCount} unsaved change${totalStagedCount !== 1 ? "s" : ""}. What would you like to do?`,
+ submitAndLeave,
);
useDocumentTitle(`${name} - ${useInstanceName()} (Settings)`);
+ // Ctrl+S / Cmd+S keyboard shortcut
+ useEffect(() => {
+ const handler = (e: KeyboardEvent) => {
+ if ((e.ctrlKey || e.metaKey) && e.key === "s") {
+ e.preventDefault();
+ if (totalStagedCount > 0) {
+ form.onSubmit(submit)();
+ }
+ }
+ };
+ document.addEventListener("keydown", handler);
+ return () => document.removeEventListener("keydown", handler);
+ }, [form, submit, totalStagedCount]);
+
return (
diff --git a/frontend/src/pages/SubtitleEditor/CueTable.tsx b/frontend/src/pages/SubtitleEditor/CueTable.tsx
new file mode 100644
index 0000000000..c9a28edd05
--- /dev/null
+++ b/frontend/src/pages/SubtitleEditor/CueTable.tsx
@@ -0,0 +1,397 @@
+import { Fragment, ReactNode, useRef } from "react";
+import { useVirtualizer } from "@tanstack/react-virtual";
+import type { Cue } from "./types";
+
+function formatTimestamp(ms: number): string {
+ const totalSeconds = Math.floor(ms / 1000);
+ const hours = Math.floor(totalSeconds / 3600);
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
+ const seconds = totalSeconds % 60;
+ const millis = ms % 1000;
+
+ return (
+ String(hours).padStart(2, "0") +
+ ":" +
+ String(minutes).padStart(2, "0") +
+ ":" +
+ String(seconds).padStart(2, "0") +
+ "." +
+ String(millis).padStart(3, "0")
+ );
+}
+
+function formatDuration(ms: number): string {
+ const seconds = ms / 1000;
+ if (Number.isInteger(seconds)) {
+ return seconds + ".0s";
+ }
+ return parseFloat(seconds.toFixed(1)) + "s";
+}
+
+// Tag colors by type
+// Badge color categories
+const TAG_COLORS: Record = {
+ // HTML style tags
+ i: { bg: "rgba(139, 92, 246, 0.2)", text: "#a78bfa" },
+ b: { bg: "rgba(251, 146, 60, 0.2)", text: "#fb923c" },
+ u: { bg: "rgba(52, 211, 153, 0.2)", text: "#34d399" },
+ font: { bg: "rgba(96, 165, 250, 0.2)", text: "#60a5fa" },
+ s: { bg: "rgba(248, 113, 113, 0.2)", text: "#f87171" },
+ // ASS categories
+ pos: { bg: "rgba(251, 191, 36, 0.2)", text: "#fbbf24" },
+ align: { bg: "rgba(251, 191, 36, 0.2)", text: "#fbbf24" },
+ effect: { bg: "rgba(168, 85, 247, 0.2)", text: "#a855f7" },
+ color: { bg: "rgba(96, 165, 250, 0.2)", text: "#60a5fa" },
+ fontAss: { bg: "rgba(96, 165, 250, 0.2)", text: "#60a5fa" },
+};
+
+const DEFAULT_TAG_COLOR = { bg: "rgba(148, 163, 184, 0.15)", text: "#94a3b8" };
+
+function tagBadgeStyle(colorKey: string): React.CSSProperties {
+ const colors = TAG_COLORS[colorKey] ?? DEFAULT_TAG_COLOR;
+ return {
+ display: "inline-block",
+ fontSize: "0.6rem",
+ fontFamily: "monospace",
+ fontWeight: 600,
+ lineHeight: 1,
+ padding: "2px 5px",
+ borderRadius: "3px",
+ backgroundColor: colors.bg,
+ color: colors.text,
+ verticalAlign: "middle",
+ marginInline: "1px",
+ };
+}
+
+/**
+ * Categorize an ASS override tag block like {\an8} or {\pos(320,50)\fad(1200,0)}
+ * into a readable badge label and color category.
+ */
+function parseAssTagBlock(block: string): { label: string; colorKey: string } {
+ // Remove the outer braces
+ const inner = block.slice(1, -1);
+
+ // Common ASS tags and their human-readable labels
+ const patterns: [RegExp, string, string][] = [
+ // Alignment: \an1-\an9, \a1-\a11
+ [/^\\an(\d)$/, "align-$1", "align"],
+ [/^\\a(\d+)$/, "align-$1", "align"],
+ // Position
+ [/\\pos\([^)]+\)/, "pos", "pos"],
+ // Move
+ [/\\move\([^)]+\)/, "move", "pos"],
+ // Origin
+ [/\\org\([^)]+\)/, "org", "pos"],
+ // Fade
+ [/\\fad\([^)]+\)/, "fade", "effect"],
+ [/\\fade\([^)]+\)/, "fade", "effect"],
+ // Blur
+ [/\\be\d/, "blur", "effect"],
+ [/\\blur[\d.]/, "blur", "effect"],
+ // Border/shadow
+ [/\\bord[\d.]/, "border", "effect"],
+ [/\\shad[\d.]/, "shadow", "effect"],
+ // Rotation
+ [/\\fr[xyz]?[\d.-]/, "rotate", "effect"],
+ // Scale
+ [/\\fsc[xy][\d.]/, "scale", "effect"],
+ // Color
+ [/\\1?c&H/, "color", "color"],
+ [/\\2c&H/, "color2", "color"],
+ [/\\3c&H/, "border-color", "color"],
+ [/\\4c&H/, "shadow-color", "color"],
+ // Alpha
+ [/\\alpha/, "alpha", "effect"],
+ [/\\[1234]a&H/, "alpha", "effect"],
+ // Font
+ [/\\fn/, "font", "fontAss"],
+ [/\\fs[\d.]/, "size", "fontAss"],
+ // Bold/italic in ASS
+ [/\\b[01]/, "bold", "b"],
+ [/\\i[01]/, "italic", "i"],
+ [/\\u[01]/, "underline", "u"],
+ [/\\s[01]/, "strike", "s"],
+ // Karaoke
+ [/\\k[fo]?\d/, "karaoke", "effect"],
+ // Clip
+ [/\\i?clip\(/, "clip", "effect"],
+ // Drawing
+ [/\\p[01]/, "drawing", "effect"],
+ // Letter spacing
+ [/\\fsp[\d.-]/, "spacing", "fontAss"],
+ // Encoding
+ [/\\fe\d/, "encoding", "fontAss"],
+ // Animated transform
+ [/\\t\(/, "animate", "effect"],
+ // Wrap
+ [/\\q\d/, "wrap", "align"],
+ // Reset
+ [/\\r/, "reset", "effect"],
+ ];
+
+ // Try to match known tags
+ const labels: string[] = [];
+ let colorKey = "effect";
+
+ for (const [pattern, label, category] of patterns) {
+ if (pattern.test(inner)) {
+ labels.push(label.replace("$1", inner.match(/\d+/)?.[0] ?? ""));
+ colorKey = category;
+ }
+ }
+
+ if (labels.length > 0) {
+ return { label: labels.join(" "), colorKey };
+ }
+
+ // Fallback: show the raw content, abbreviated
+ const abbreviated = inner.length > 12 ? inner.slice(0, 12) + ".." : inner;
+ return { label: abbreviated, colorKey: "effect" };
+}
+
+/**
+ * Renders cue text with formatting tags shown as small colored badges.
+ * Handles both HTML tags (, , ) and ASS override tags ({\an8}, {\pos}).
+ */
+function renderStyledText(text: string): ReactNode {
+ // Match HTML tags and ASS override tag blocks
+ // HTML: , , , , , etc.
+ // ASS: {\an8}, {\pos(320,50)}, {\fad(1200,0)\be1}, etc.
+ const tagPattern = /<\/?([a-zA-Z]+)(?:\s[^>]*)?>|\{\\[^}]+\}/g;
+ const parts: ReactNode[] = [];
+ let lastIndex = 0;
+ let match: RegExpExecArray | null;
+ let key = 0;
+
+ while ((match = tagPattern.exec(text)) !== null) {
+ // Text before the tag
+ if (match.index > lastIndex) {
+ parts.push(
+ {text.slice(lastIndex, match.index)} ,
+ );
+ }
+
+ const fullMatch = match[0];
+
+ if (fullMatch.startsWith("{\\")) {
+ // ASS override tag block
+ const { label, colorKey } = parseAssTagBlock(fullMatch);
+ parts.push(
+
+ {label}
+ ,
+ );
+ } else {
+ // HTML tag
+ const tagName = match[1].toLowerCase();
+ const isClosing = fullMatch.startsWith("");
+ parts.push(
+
+ {isClosing ? `/${tagName}` : tagName}
+ ,
+ );
+ }
+
+ lastIndex = match.index + fullMatch.length;
+ }
+
+ // Remaining text after last tag
+ if (lastIndex < text.length) {
+ parts.push({text.slice(lastIndex)} );
+ }
+
+ return parts.length > 0 ? parts : text;
+}
+
+const gridTemplate = "56px 140px 140px 72px 1fr";
+
+const headerStyle: React.CSSProperties = {
+ display: "grid",
+ gridTemplateColumns: gridTemplate,
+ minWidth: 700,
+ position: "sticky",
+ top: 0,
+ zIndex: 1,
+ backgroundColor: "var(--bz-surface-base)",
+ borderBottom: "2px solid var(--bz-border-divider)",
+ fontWeight: 700,
+ fontSize: "0.7rem",
+ textTransform: "uppercase",
+ letterSpacing: "0.8px",
+ color: "var(--bz-text-tertiary)",
+};
+
+const headerCellStyle: React.CSSProperties = {
+ padding: "10px 12px",
+};
+
+const rowBaseStyle: React.CSSProperties = {
+ display: "grid",
+ gridTemplateColumns: gridTemplate,
+ minWidth: 700,
+ alignItems: "start",
+ borderBottom: "1px solid var(--bz-border-divider)",
+ transition: "background-color 0.1s ease",
+};
+
+const cellStyle: React.CSSProperties = {
+ padding: "10px 12px",
+ fontSize: "0.85rem",
+ lineHeight: 1.5,
+};
+
+const monoStyle: React.CSSProperties = {
+ ...cellStyle,
+ fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace",
+ fontSize: "0.8rem",
+ color: "var(--mantine-color-yellow-4)",
+ letterSpacing: "-0.2px",
+};
+
+const durationStyle: React.CSSProperties = {
+ ...cellStyle,
+ fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace",
+ fontSize: "0.8rem",
+ color: "var(--bz-text-tertiary)",
+ textAlign: "center",
+};
+
+const textCellStyle: React.CSSProperties = {
+ ...cellStyle,
+ whiteSpace: "pre-wrap",
+ wordBreak: "break-word",
+ color: "var(--mantine-color-text)",
+};
+
+const indexCellStyle: React.CSSProperties = {
+ ...cellStyle,
+ color: "var(--bz-text-tertiary)",
+ fontSize: "0.75rem",
+ textAlign: "right",
+ fontFamily: "monospace",
+ paddingRight: "16px",
+};
+
+const COLUMNS = ["#", "START", "END", "DURATION", "TEXT"] as const;
+
+interface CueTableProps {
+ cues: Cue[];
+}
+
+export default function CueTable({ cues }: CueTableProps) {
+ const scrollRef = useRef(null);
+
+ const virtualizer = useVirtualizer({
+ count: cues.length,
+ getScrollElement: () => scrollRef.current,
+ estimateSize: () => 48,
+ overscan: 10,
+ });
+
+ return (
+
+
+ {COLUMNS.map((col) => (
+
+ {col}
+
+ ))}
+
+
+
+
+ {virtualizer.getVirtualItems().map((virtualRow) => {
+ const cue = cues[virtualRow.index];
+ const isOdd = virtualRow.index % 2 === 1;
+
+ return (
+
{
+ e.currentTarget.style.backgroundColor =
+ "var(--bz-hover-bg-emphasis)";
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.backgroundColor = isOdd
+ ? "var(--bz-hover-bg)"
+ : "transparent";
+ }}
+ >
+
+ {virtualRow.index + 1}
+
+
+ {formatTimestamp(cue.startMs)}
+
+
+ {formatTimestamp(cue.endMs)}
+
+
+ {formatDuration(cue.endMs - cue.startMs)}
+
+
+ {renderStyledText(cue.text)}
+
+
+ );
+ })}
+
+
+
+ );
+}
+
+export { formatTimestamp, formatDuration };
diff --git a/frontend/src/pages/SubtitleEditor/__tests__/parsers.test.ts b/frontend/src/pages/SubtitleEditor/__tests__/parsers.test.ts
new file mode 100644
index 0000000000..a6ec67becc
--- /dev/null
+++ b/frontend/src/pages/SubtitleEditor/__tests__/parsers.test.ts
@@ -0,0 +1,280 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { getParser } from "@/pages/SubtitleEditor/parsers";
+
+// Mock crypto.randomUUID since it may not be available in test env
+beforeEach(() => {
+ let counter = 0;
+ vi.stubGlobal("crypto", {
+ randomUUID: () => `test-uuid-${++counter}`,
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Tier 1: SRT
+// ---------------------------------------------------------------------------
+describe("SRT parser", () => {
+ const parser = getParser("srt");
+
+ it("parses a basic SRT file with 2 cues", () => {
+ const input = [
+ "1",
+ "00:00:01,000 --> 00:00:02,500",
+ "Hello world",
+ "",
+ "2",
+ "00:01:00,000 --> 00:01:03,400",
+ "Second cue",
+ ].join("\n");
+
+ const result = parser.parse(input);
+ expect(result.cues).toHaveLength(2);
+
+ expect(result.cues[0].startMs).toBe(1000);
+ expect(result.cues[0].endMs).toBe(2500);
+ expect(result.cues[0].text).toBe("Hello world");
+
+ expect(result.cues[1].startMs).toBe(60000);
+ expect(result.cues[1].endMs).toBe(63400);
+ expect(result.cues[1].text).toBe("Second cue");
+
+ expect(result.metadata.format).toBe("srt");
+ });
+
+ it("returns 0 cues for empty input", () => {
+ const result = parser.parse("");
+ expect(result.cues).toHaveLength(0);
+ });
+
+ it("handles multiline cue text", () => {
+ const input = [
+ "1",
+ "00:00:01,000 --> 00:00:04,000",
+ "Line one",
+ "Line two",
+ "Line three",
+ ].join("\n");
+
+ const result = parser.parse(input);
+ expect(result.cues).toHaveLength(1);
+ expect(result.cues[0].text).toBe("Line one\nLine two\nLine three");
+ });
+
+ it("handles Windows line endings (\\r\\n)", () => {
+ const input =
+ "1\r\n00:00:01,000 --> 00:00:02,000\r\nHello\r\n\r\n2\r\n00:00:03,000 --> 00:00:04,000\r\nWorld";
+
+ const result = parser.parse(input);
+ expect(result.cues).toHaveLength(2);
+ expect(result.cues[0].text).toBe("Hello");
+ expect(result.cues[1].text).toBe("World");
+ });
+
+ it("handles BOM prefix", () => {
+ const input = "\uFEFF1\n00:00:01,000 --> 00:00:02,000\nWith BOM";
+
+ const result = parser.parse(input);
+ expect(result.cues).toHaveLength(1);
+ expect(result.cues[0].text).toBe("With BOM");
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Tier 1: ASS/SSA
+// ---------------------------------------------------------------------------
+describe("ASS parser", () => {
+ const parser = getParser("ass");
+
+ const minimalASS = [
+ "[Script Info]",
+ "Title: Test Script",
+ "ScriptType: v4.00+",
+ "",
+ "[V4+ Styles]",
+ "Format: Name, Fontname, Fontsize",
+ "Style: Default,Arial,20",
+ "",
+ "[Events]",
+ "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text",
+ "Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0000,0000,0000,,Hello world",
+ "Dialogue: 0,0:01:00.50,0:01:03.00,Default,,0000,0000,0000,,{\\b1}Bold text{\\b0}\\NSecond line",
+ ].join("\n");
+
+ it("parses a minimal ASS file with Script Info, Styles, and Dialogue", () => {
+ const result = parser.parse(minimalASS);
+ expect(result.metadata.format).toBe("ass");
+ expect(result.metadata.title).toBe("Test Script");
+ expect(result.cues).toHaveLength(2);
+
+ expect(result.cues[0].startMs).toBe(1000);
+ expect(result.cues[0].endMs).toBe(4000);
+ expect(result.cues[0].text).toBe("Hello world");
+ });
+
+ it("preserves the full original Dialogue line in rawText", () => {
+ const result = parser.parse(minimalASS);
+ expect(result.cues[1].rawText).toBe(
+ "Dialogue: 0,0:01:00.50,0:01:03.00,Default,,0000,0000,0000,,{\\b1}Bold text{\\b0}\\NSecond line",
+ );
+ });
+
+ it("strips override tags and converts \\N to newlines in text", () => {
+ const result = parser.parse(minimalASS);
+ expect(result.cues[1].text).toBe("Bold text\nSecond line");
+ });
+
+ it("includes header sections in metadata.rawHeader", () => {
+ const result = parser.parse(minimalASS);
+ const rawHeader = result.metadata.rawHeader as string;
+ expect(rawHeader).toContain("[Script Info]");
+ expect(rawHeader).toContain("[V4+ Styles]");
+ expect(rawHeader).not.toContain("[Events]");
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Tier 1: VTT
+// ---------------------------------------------------------------------------
+describe("VTT parser", () => {
+ const parser = getParser("vtt");
+
+ it("parses with WEBVTT header", () => {
+ const input = [
+ "WEBVTT",
+ "",
+ "00:00:01.000 --> 00:00:04.000",
+ "Hello world",
+ "",
+ "00:01:00.000 --> 00:01:03.500",
+ "Second cue",
+ ].join("\n");
+
+ const result = parser.parse(input);
+ expect(result.metadata.format).toBe("vtt");
+ expect(result.cues).toHaveLength(2);
+
+ expect(result.cues[0].startMs).toBe(1000);
+ expect(result.cues[0].endMs).toBe(4000);
+ expect(result.cues[0].text).toBe("Hello world");
+
+ expect(result.cues[1].startMs).toBe(60000);
+ expect(result.cues[1].endMs).toBe(63500);
+ expect(result.cues[1].text).toBe("Second cue");
+ });
+
+ it("handles optional cue identifiers", () => {
+ const input = [
+ "WEBVTT",
+ "",
+ "intro",
+ "00:00:01.000 --> 00:00:04.000",
+ "With identifier",
+ ].join("\n");
+
+ const result = parser.parse(input);
+ expect(result.cues).toHaveLength(1);
+ expect(result.cues[0].text).toBe("With identifier");
+ });
+
+ it("handles position/alignment settings in timestamp line", () => {
+ const input = [
+ "WEBVTT",
+ "",
+ "00:00:01.000 --> 00:00:04.000 position:10% align:start",
+ "Positioned cue",
+ ].join("\n");
+
+ const result = parser.parse(input);
+ expect(result.cues).toHaveLength(1);
+ expect(result.cues[0].startMs).toBe(1000);
+ expect(result.cues[0].endMs).toBe(4000);
+ expect(result.cues[0].text).toBe("Positioned cue");
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Tier 2: SUB (MicroDVD)
+// ---------------------------------------------------------------------------
+describe("SUB parser", () => {
+ it("parses frame-based lines and converts to ms at 23.976fps", () => {
+ const parser = getParser("sub");
+ const input = "{0}{100}Hello";
+
+ const result = parser.parse(input);
+ expect(result.cues).toHaveLength(1);
+ expect(result.metadata.format).toBe("sub");
+
+ // 0 frames = 0ms, 100 frames at 23.976fps = Math.round((100/23.976)*1000)
+ expect(result.cues[0].startMs).toBe(0);
+ expect(result.cues[0].endMs).toBe(Math.round((100 / 23.976) * 1000));
+ expect(result.cues[0].text).toBe("Hello");
+ expect(result.cues[0].rawText).toBe("{0}{100}Hello");
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Tier 2: SMI
+// ---------------------------------------------------------------------------
+describe("SMI parser", () => {
+ it("parses SYNC-based content", () => {
+ const parser = getParser("smi");
+ const input = [
+ "",
+ "",
+ "Hello",
+ " ",
+ "",
+ " ",
+ ].join("\n");
+
+ const result = parser.parse(input);
+ expect(result.metadata.format).toBe("smi");
+ expect(result.cues).toHaveLength(1);
+ expect(result.cues[0].startMs).toBe(1000);
+ expect(result.cues[0].endMs).toBe(4000);
+ expect(result.cues[0].text).toBe("Hello");
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Tier 2: TXT (TMPlayer)
+// ---------------------------------------------------------------------------
+describe("TXT parser", () => {
+ it("parses TMPlayer format", () => {
+ const parser = getParser("txt");
+ const input = ["00:01:00:Hello", "00:02:00:World"].join("\n");
+
+ const result = parser.parse(input);
+ expect(result.metadata.format).toBe("txt");
+ expect(result.cues).toHaveLength(2);
+
+ expect(result.cues[0].startMs).toBe(60000);
+ // End time adjusted to next cue's start
+ expect(result.cues[0].endMs).toBe(120000);
+ expect(result.cues[0].text).toBe("Hello");
+
+ expect(result.cues[1].startMs).toBe(120000);
+ // Last cue gets startMs + 3000
+ expect(result.cues[1].endMs).toBe(123000);
+ expect(result.cues[1].text).toBe("World");
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Tier 2: MPL
+// ---------------------------------------------------------------------------
+describe("MPL parser", () => {
+ it("parses decisecond-based lines and converts to ms", () => {
+ const parser = getParser("mpl");
+ const input = "[100][200]Hello";
+
+ const result = parser.parse(input);
+ expect(result.metadata.format).toBe("mpl");
+ expect(result.cues).toHaveLength(1);
+
+ // 100 deciseconds = 10000ms, 200 deciseconds = 20000ms
+ expect(result.cues[0].startMs).toBe(10000);
+ expect(result.cues[0].endMs).toBe(20000);
+ expect(result.cues[0].text).toBe("Hello");
+ expect(result.cues[0].rawText).toBe("[100][200]Hello");
+ });
+});
diff --git a/frontend/src/pages/SubtitleEditor/index.tsx b/frontend/src/pages/SubtitleEditor/index.tsx
new file mode 100644
index 0000000000..861eefcc90
--- /dev/null
+++ b/frontend/src/pages/SubtitleEditor/index.tsx
@@ -0,0 +1,126 @@
+import { useMemo } from "react";
+import { Link, useParams } from "react-router";
+import {
+ Alert,
+ Anchor,
+ Badge,
+ Breadcrumbs,
+ Center,
+ Group,
+ Loader,
+ Stack,
+ Text,
+} from "@mantine/core";
+import { useSubtitleContent } from "@/apis/hooks/subtitles";
+import CueTable from "./CueTable";
+import { getParser } from "./parsers";
+import type { ParseResult, SubtitleFormat } from "./types";
+
+function formatFileSize(bytes: number): string {
+ if (bytes < 1024) return bytes + " B";
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
+ return (bytes / (1024 * 1024)).toFixed(1) + " MB";
+}
+
+export default function SubtitleEditor() {
+ const { mediaType, mediaId, language } = useParams();
+
+ const { data, isLoading, error } = useSubtitleContent(
+ mediaType,
+ mediaId ? Number(mediaId) : undefined,
+ language,
+ );
+
+ const parseResult = useMemo(() => {
+ if (!data) return null;
+ try {
+ return getParser(data.format as SubtitleFormat).parse(data.content);
+ } catch {
+ return null;
+ }
+ }, [data]);
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (error) {
+ return (
+
+ {error.message || "Failed to load subtitle content"}
+
+ );
+ }
+
+ if (data && !parseResult) {
+ return (
+
+ Failed to parse subtitle file
+
+ );
+ }
+
+ if (!data || !parseResult) {
+ return null;
+ }
+
+ const isSeries = mediaType === "episode" || mediaType === "series";
+ const listPath = isSeries ? "/series" : "/movies";
+ const listLabel = isSeries ? "Series" : "Movies";
+ const detailPath = data.mediaId
+ ? isSeries
+ ? `/series/${data.mediaId}`
+ : `/movies/${data.mediaId}`
+ : undefined;
+
+ return (
+
+
+
+
+ {listLabel}
+
+ {data.mediaTitle && detailPath && (
+
+ {data.mediaTitle}
+
+ )}
+ Subtitle Viewer
+
+
+
+ {data.format.toUpperCase()}
+
+ {data.language && (
+
+ {data.language}
+
+ )}
+
+ {data.encoding}
+
+
+ {parseResult.cues.length} cues
+
+
+ {formatFileSize(data.size)}
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/SubtitleEditor/parsers/ass.ts b/frontend/src/pages/SubtitleEditor/parsers/ass.ts
new file mode 100644
index 0000000000..c7abee06f0
--- /dev/null
+++ b/frontend/src/pages/SubtitleEditor/parsers/ass.ts
@@ -0,0 +1,143 @@
+import type { Cue, ParseResult } from "@/pages/SubtitleEditor/types";
+import type { SubtitleParser } from "./index";
+
+function parseTimestamp(raw: string): number {
+ // H:MM:SS.cc (centiseconds)
+ const match = raw.trim().match(/^(\d+):(\d+):(\d+)\.(\d+)$/);
+ if (!match) return 0;
+ const [, h, m, s, cs] = match;
+ return (
+ parseInt(h, 10) * 3600000 +
+ parseInt(m, 10) * 60000 +
+ parseInt(s, 10) * 1000 +
+ parseInt(cs.padEnd(2, "0").slice(0, 2), 10) * 10
+ );
+}
+
+function stripOverrideTags(text: string): string {
+ // Remove {...} override blocks
+ return text.replace(/\{[^}]*\}/g, "");
+}
+
+function assTextToDisplay(text: string): string {
+ let clean = stripOverrideTags(text);
+ // \N and \n both become newlines for display
+ clean = clean.replace(/\\N/g, "\n").replace(/\\n/g, "\n");
+ return clean;
+}
+
+export const assParser: SubtitleParser = {
+ parse(content: string): ParseResult {
+ const text = content.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n");
+
+ if (!text.trim()) {
+ return { metadata: { format: "ass" }, cues: [] };
+ }
+
+ // Determine format variant
+ const isSSA =
+ !text.includes("[V4+ Styles]") && text.includes("[V4 Styles]");
+ const format = isSSA ? "ssa" : "ass";
+
+ // Split into sections
+ const sections: Record = {};
+ let currentSection = "";
+ const lines = text.split("\n");
+
+ for (const line of lines) {
+ const sectionMatch = line.match(/^\[([^\]]+)\]/);
+ if (sectionMatch) {
+ currentSection = sectionMatch[1];
+ sections[currentSection] = "";
+ } else if (currentSection) {
+ sections[currentSection] += line + "\n";
+ }
+ }
+
+ // Build rawHeader from everything before [Events]
+ const eventsIdx = text.indexOf("[Events]");
+ const rawHeader = eventsIdx >= 0 ? text.slice(0, eventsIdx) : text;
+
+ // Extract title from Script Info
+ let title: string | undefined;
+ const scriptInfo = sections["Script Info"] ?? "";
+ const titleMatch = scriptInfo.match(/^Title:\s*(.+)$/m);
+ if (titleMatch) {
+ title = titleMatch[1].trim();
+ }
+
+ // Extract styles
+ const stylesKey = isSSA ? "V4 Styles" : "V4+ Styles";
+ const styles = sections[stylesKey]?.trim() || undefined;
+
+ // Parse events
+ const eventsText = sections["Events"] ?? "";
+ const eventsLines = eventsText.split("\n").filter((l) => l.trim());
+
+ // Find Format line to determine column order
+ const formatLine = eventsLines.find((l) => l.trim().startsWith("Format:"));
+ let formatColumns: string[] = [];
+ if (formatLine) {
+ formatColumns = formatLine
+ .replace(/^Format:\s*/, "")
+ .split(",")
+ .map((c) => c.trim());
+ }
+
+ const textColIdx = formatColumns.findIndex(
+ (c) => c.toLowerCase() === "text",
+ );
+ const startColIdx = formatColumns.findIndex(
+ (c) => c.toLowerCase() === "start",
+ );
+ const endColIdx = formatColumns.findIndex((c) => c.toLowerCase() === "end");
+
+ const cues: Cue[] = [];
+
+ for (const line of eventsLines) {
+ if (!line.trim().startsWith("Dialogue:")) continue;
+
+ const afterPrefix = line.replace(/^Dialogue:\s*/, "");
+ // Split only up to (columns - 1) commas, because the Text field can contain commas
+ const maxSplits = formatColumns.length - 1;
+ const parts: string[] = [];
+ let remaining = afterPrefix;
+ for (let i = 0; i < maxSplits; i++) {
+ const commaIdx = remaining.indexOf(",");
+ if (commaIdx === -1) break;
+ parts.push(remaining.slice(0, commaIdx).trim());
+ remaining = remaining.slice(commaIdx + 1);
+ }
+ parts.push(remaining); // The rest is the Text column (or last column)
+
+ const rawCueText =
+ textColIdx >= 0 && textColIdx < parts.length
+ ? parts[textColIdx]
+ : parts[parts.length - 1];
+ const startStr =
+ startColIdx >= 0 && startColIdx < parts.length
+ ? parts[startColIdx]
+ : "";
+ const endStr =
+ endColIdx >= 0 && endColIdx < parts.length ? parts[endColIdx] : "";
+
+ cues.push({
+ id: crypto.randomUUID(),
+ startMs: parseTimestamp(startStr),
+ endMs: parseTimestamp(endStr),
+ text: assTextToDisplay(rawCueText),
+ rawText: line,
+ });
+ }
+
+ return {
+ metadata: {
+ format,
+ title,
+ styles,
+ rawHeader: rawHeader.trimEnd(),
+ },
+ cues,
+ };
+ },
+};
diff --git a/frontend/src/pages/SubtitleEditor/parsers/index.ts b/frontend/src/pages/SubtitleEditor/parsers/index.ts
new file mode 100644
index 0000000000..69211fb336
--- /dev/null
+++ b/frontend/src/pages/SubtitleEditor/parsers/index.ts
@@ -0,0 +1,46 @@
+import type { ParseResult, SubtitleFormat } from "@/pages/SubtitleEditor/types";
+import { assParser } from "./ass";
+import { mplParser } from "./mpl";
+import { smiParser } from "./smi";
+import { srtParser } from "./srt";
+import { subParser } from "./sub";
+import { txtParser } from "./txt";
+import { vttParser } from "./vtt";
+
+export interface SubtitleParser {
+ parse(content: string): ParseResult;
+}
+
+const parsers: Record = {
+ srt: srtParser,
+ ass: assParser,
+ ssa: assParser,
+ vtt: vttParser,
+ sub: subParser,
+ smi: smiParser,
+ txt: txtParser,
+ mpl: mplParser,
+};
+
+export function getParser(format: SubtitleFormat): SubtitleParser {
+ return parsers[format];
+}
+
+const extensionMap: Record = {
+ ".srt": "srt",
+ ".ass": "ass",
+ ".ssa": "ssa",
+ ".vtt": "vtt",
+ ".sub": "sub",
+ ".smi": "smi",
+ ".sami": "smi",
+ ".txt": "txt",
+ ".mpl": "mpl",
+};
+
+export function detectFormat(filename: string): SubtitleFormat {
+ const dot = filename.lastIndexOf(".");
+ if (dot === -1) return "srt";
+ const ext = filename.slice(dot).toLowerCase();
+ return extensionMap[ext] ?? "srt";
+}
diff --git a/frontend/src/pages/SubtitleEditor/parsers/mpl.ts b/frontend/src/pages/SubtitleEditor/parsers/mpl.ts
new file mode 100644
index 0000000000..2770eb58fe
--- /dev/null
+++ b/frontend/src/pages/SubtitleEditor/parsers/mpl.ts
@@ -0,0 +1,36 @@
+import type { ParseResult } from "@/pages/SubtitleEditor/types";
+import type { SubtitleParser } from "./index";
+
+export const mplParser: SubtitleParser = {
+ parse(content: string): ParseResult {
+ const text = content.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n");
+
+ if (!text.trim()) {
+ return { metadata: { format: "mpl" }, cues: [] };
+ }
+
+ const lines = text.split("\n").filter((l) => l.trim());
+ const cues = [];
+ // MPL2 format: [start_ds][end_ds]text
+ // ds = deciseconds (1/10 second)
+ const pattern = /^\[(\d+)\]\[(\d+)\](.*)$/;
+
+ for (const line of lines) {
+ const match = line.match(pattern);
+ if (!match) continue;
+
+ const [, startDs, endDs, rawText] = match;
+ const displayText = rawText.replace(/\|/g, "\n");
+
+ cues.push({
+ id: crypto.randomUUID(),
+ startMs: parseInt(startDs, 10) * 100,
+ endMs: parseInt(endDs, 10) * 100,
+ text: displayText,
+ rawText: line,
+ });
+ }
+
+ return { metadata: { format: "mpl" }, cues };
+ },
+};
diff --git a/frontend/src/pages/SubtitleEditor/parsers/smi.ts b/frontend/src/pages/SubtitleEditor/parsers/smi.ts
new file mode 100644
index 0000000000..c6fe88a57a
--- /dev/null
+++ b/frontend/src/pages/SubtitleEditor/parsers/smi.ts
@@ -0,0 +1,79 @@
+import type { ParseResult } from "@/pages/SubtitleEditor/types";
+import type { SubtitleParser } from "./index";
+
+function stripHtml(html: string): string {
+ return html
+ .replace(/ /gi, "\n")
+ .replace(/<[^>]+>/g, "")
+ .replace(/ /gi, " ")
+ .replace(/</gi, "<")
+ .replace(/>/gi, ">")
+ .replace(/&/gi, "&")
+ .trim();
+}
+
+export const smiParser: SubtitleParser = {
+ parse(content: string): ParseResult {
+ const text = content.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n");
+
+ if (!text.trim()) {
+ return { metadata: { format: "smi" }, cues: [] };
+ }
+
+ // Find all SYNC tags with their start times and collect text between them
+ const syncPattern = //gi;
+ const syncs: { startMs: number; index: number }[] = [];
+ let match: RegExpExecArray | null;
+
+ while ((match = syncPattern.exec(text)) !== null) {
+ syncs.push({
+ startMs: parseInt(match[1], 10),
+ index: match.index + match[0].length,
+ });
+ }
+
+ // Extract title if present
+ let title: string | undefined;
+ const titleMatch = text.match(/]*>([\s\S]*?)<\/TITLE>/i);
+ if (titleMatch) {
+ title = stripHtml(titleMatch[1]);
+ }
+
+ const cues = [];
+
+ for (let i = 0; i < syncs.length; i++) {
+ const start = syncs[i];
+ // Get text from after this SYNC tag to the start of the next SYNC tag (or end of text)
+ const nextSyncPattern = / b.trim());
+ const cues = [];
+
+ for (const block of blocks) {
+ const lines = block.trim().split("\n");
+ if (lines.length < 2) continue;
+
+ // Find the timestamp line. It could be line 0 (missing sequence number)
+ // or line 1 (normal case).
+ let tsLineIdx = -1;
+ for (let i = 0; i < Math.min(lines.length, 2); i++) {
+ if (lines[i].includes("-->")) {
+ tsLineIdx = i;
+ break;
+ }
+ }
+ if (tsLineIdx === -1) continue;
+
+ const tsParts = lines[tsLineIdx].split("-->");
+ if (tsParts.length < 2) continue;
+
+ const startMs = parseTimestamp(tsParts[0]);
+ const endMs = parseTimestamp(tsParts[1].trim().split(/\s/)[0]);
+ const cueText = lines.slice(tsLineIdx + 1).join("\n");
+
+ cues.push({
+ id: crypto.randomUUID(),
+ startMs,
+ endMs,
+ text: cueText,
+ });
+ }
+
+ return { metadata: { format: "srt" }, cues };
+ },
+};
diff --git a/frontend/src/pages/SubtitleEditor/parsers/sub.ts b/frontend/src/pages/SubtitleEditor/parsers/sub.ts
new file mode 100644
index 0000000000..3717a816f7
--- /dev/null
+++ b/frontend/src/pages/SubtitleEditor/parsers/sub.ts
@@ -0,0 +1,39 @@
+import type { ParseResult } from "@/pages/SubtitleEditor/types";
+import type { SubtitleParser } from "./index";
+
+const DEFAULT_FPS = 23.976;
+
+function framesToMs(frame: number, fps: number): number {
+ return Math.round((frame / fps) * 1000);
+}
+
+export const subParser: SubtitleParser = {
+ parse(content: string): ParseResult {
+ const text = content.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n");
+
+ if (!text.trim()) {
+ return { metadata: { format: "sub" }, cues: [] };
+ }
+
+ const lines = text.split("\n").filter((l) => l.trim());
+ const cues = [];
+
+ for (const line of lines) {
+ const match = line.match(/^\{(\d+)\}\{(\d+)\}(.*)$/);
+ if (!match) continue;
+
+ const [, startFrame, endFrame, rawText] = match;
+ const displayText = rawText.replace(/\|/g, "\n");
+
+ cues.push({
+ id: crypto.randomUUID(),
+ startMs: framesToMs(parseInt(startFrame, 10), DEFAULT_FPS),
+ endMs: framesToMs(parseInt(endFrame, 10), DEFAULT_FPS),
+ text: displayText,
+ rawText: line,
+ });
+ }
+
+ return { metadata: { format: "sub" }, cues };
+ },
+};
diff --git a/frontend/src/pages/SubtitleEditor/parsers/txt.ts b/frontend/src/pages/SubtitleEditor/parsers/txt.ts
new file mode 100644
index 0000000000..6ccb2b0a66
--- /dev/null
+++ b/frontend/src/pages/SubtitleEditor/parsers/txt.ts
@@ -0,0 +1,78 @@
+import type { ParseResult } from "@/pages/SubtitleEditor/types";
+import type { SubtitleParser } from "./index";
+
+function parseTimestampToMs(h: string, m: string, s: string): number {
+ return (
+ parseInt(h, 10) * 3600000 + parseInt(m, 10) * 60000 + parseInt(s, 10) * 1000
+ );
+}
+
+export const txtParser: SubtitleParser = {
+ parse(content: string): ParseResult {
+ const text = content.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n");
+
+ if (!text.trim()) {
+ return { metadata: { format: "txt" }, cues: [] };
+ }
+
+ const lines = text.split("\n").filter((l) => l.trim());
+ const cues = [];
+
+ // Try TMPlayer format: HH:MM:SS:text or HH:MM:SS=text
+ const tmPlayerPattern = /^(\d+):(\d+):(\d+)[:=](.+)$/;
+ let isTmPlayer = false;
+
+ // Check first non-empty line to detect format
+ if (lines.length > 0 && tmPlayerPattern.test(lines[0])) {
+ isTmPlayer = true;
+ }
+
+ if (isTmPlayer) {
+ for (const line of lines) {
+ const match = line.match(tmPlayerPattern);
+ if (!match) continue;
+
+ const [, h, m, s, cueText] = match;
+ const startMs = parseTimestampToMs(h, m, s);
+ const displayText = cueText.replace(/\|/g, "\n");
+
+ cues.push({
+ id: crypto.randomUUID(),
+ startMs,
+ endMs: startMs + 3000,
+ text: displayText,
+ });
+ }
+
+ // Adjust end times: each cue ends when the next one starts
+ for (let i = 0; i < cues.length - 1; i++) {
+ cues[i].endMs = cues[i + 1].startMs;
+ }
+ } else {
+ // Try simple numbered format: N HH:MM:SS text
+ const numberedPattern = /^(\d+)\s+(\d+):(\d+):(\d+)\s+(.+)$/;
+
+ for (const line of lines) {
+ const match = line.match(numberedPattern);
+ if (!match) continue;
+
+ const [, , h, m, s, cueText] = match;
+ const startMs = parseTimestampToMs(h, m, s);
+
+ cues.push({
+ id: crypto.randomUUID(),
+ startMs,
+ endMs: startMs + 3000,
+ text: cueText,
+ });
+ }
+
+ // Adjust end times
+ for (let i = 0; i < cues.length - 1; i++) {
+ cues[i].endMs = cues[i + 1].startMs;
+ }
+ }
+
+ return { metadata: { format: "txt" }, cues };
+ },
+};
diff --git a/frontend/src/pages/SubtitleEditor/parsers/vtt.ts b/frontend/src/pages/SubtitleEditor/parsers/vtt.ts
new file mode 100644
index 0000000000..59c7e4eef0
--- /dev/null
+++ b/frontend/src/pages/SubtitleEditor/parsers/vtt.ts
@@ -0,0 +1,126 @@
+import type { ParseResult } from "@/pages/SubtitleEditor/types";
+import type { SubtitleParser } from "./index";
+
+function parseTimestamp(raw: string): number {
+ // HH:MM:SS.mmm or MM:SS.mmm
+ const match = raw.trim().match(/^(?:(\d+):)?(\d+):(\d+)\.(\d+)$/);
+ if (!match) return 0;
+ const [, h, m, s, ms] = match;
+ return (
+ parseInt(h ?? "0", 10) * 3600000 +
+ parseInt(m, 10) * 60000 +
+ parseInt(s, 10) * 1000 +
+ parseInt(ms.padEnd(3, "0").slice(0, 3), 10)
+ );
+}
+
+export const vttParser: SubtitleParser = {
+ parse(content: string): ParseResult {
+ const text = content.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n");
+
+ if (!text.trim()) {
+ return { metadata: { format: "vtt" }, cues: [] };
+ }
+
+ const lines = text.split("\n");
+
+ // Verify WEBVTT header
+ if (!lines[0].trim().startsWith("WEBVTT")) {
+ return { metadata: { format: "vtt" }, cues: [] };
+ }
+
+ // Collect header metadata (STYLE, REGION blocks, NOTE blocks before first cue)
+ let rawHeader = "";
+ let i = 0;
+
+ // Skip WEBVTT line and any following header text
+ rawHeader += lines[i] + "\n";
+ i++;
+
+ // Collect everything before the first cue as header
+ // A cue starts with a timestamp line containing -->
+ const headerLines: string[] = [lines[0]];
+ let foundFirstCue = false;
+ for (i = 1; i < lines.length; i++) {
+ if (lines[i].includes("-->")) {
+ // Check if previous non-empty line was a cue identifier
+ foundFirstCue = true;
+ // Remove the cue ID line if it was added to header
+ const lastNonEmpty = headerLines.length - 1;
+ if (
+ lastNonEmpty >= 0 &&
+ headerLines[lastNonEmpty].trim() &&
+ !headerLines[lastNonEmpty].includes("-->") &&
+ !headerLines[lastNonEmpty].startsWith("STYLE") &&
+ !headerLines[lastNonEmpty].startsWith("REGION") &&
+ !headerLines[lastNonEmpty].startsWith("NOTE")
+ ) {
+ headerLines.pop();
+ }
+ break;
+ }
+ headerLines.push(lines[i]);
+ }
+
+ rawHeader = headerLines.join("\n").trimEnd();
+
+ // Parse cue blocks
+ const cues = [];
+ const blocks = text.split(/\n\n+/);
+
+ for (const block of blocks) {
+ const blockLines = block.trim().split("\n");
+ if (blockLines.length < 2) continue;
+
+ // Find the timestamp line
+ let tsLineIdx = -1;
+ for (let j = 0; j < blockLines.length; j++) {
+ if (blockLines[j].includes("-->")) {
+ tsLineIdx = j;
+ break;
+ }
+ }
+ if (tsLineIdx === -1) continue;
+
+ const tsLine = blockLines[tsLineIdx];
+ // Split on --> but the right side may have positioning info
+ const arrowIdx = tsLine.indexOf("-->");
+ const leftTs = tsLine.slice(0, arrowIdx).trim();
+ const rightPart = tsLine.slice(arrowIdx + 3).trim();
+ // The end timestamp is the first token; the rest are settings
+ const rightTokens = rightPart.split(/\s+/);
+ const rightTs = rightTokens[0];
+
+ const startMs = parseTimestamp(leftTs);
+ const endMs = parseTimestamp(rightTs);
+ const cueText = blockLines.slice(tsLineIdx + 1).join("\n");
+
+ if (!cueText.trim() && startMs === 0 && endMs === 0) continue;
+
+ cues.push({
+ id: crypto.randomUUID(),
+ startMs,
+ endMs,
+ text: cueText,
+ });
+ }
+
+ // Extract styles from header
+ let styles: string | undefined;
+ if (foundFirstCue) {
+ const styleMatch = rawHeader.match(/STYLE\s*\n([\s\S]*?)(?:\n\n|$)/);
+ if (styleMatch) {
+ styles = styleMatch[1].trim();
+ }
+ }
+
+ return {
+ metadata: {
+ format: "vtt",
+ styles: styles || undefined,
+ rawHeader,
+ },
+ cues,
+ };
+ },
+};
diff --git a/frontend/src/pages/SubtitleEditor/types.ts b/frontend/src/pages/SubtitleEditor/types.ts
new file mode 100644
index 0000000000..e45f0f918e
--- /dev/null
+++ b/frontend/src/pages/SubtitleEditor/types.ts
@@ -0,0 +1,30 @@
+export type SubtitleFormat =
+ | "srt"
+ | "ass"
+ | "ssa"
+ | "vtt"
+ | "sub"
+ | "smi"
+ | "txt"
+ | "mpl";
+
+export interface Cue {
+ id: string;
+ startMs: number;
+ endMs: number;
+ text: string;
+ rawText?: string;
+ formatMetadata?: unknown;
+}
+
+export interface SubtitleMetadata {
+ format: SubtitleFormat;
+ title?: string;
+ styles?: unknown;
+ rawHeader?: string;
+}
+
+export interface ParseResult {
+ metadata: SubtitleMetadata;
+ cues: Cue[];
+}
diff --git a/frontend/src/pages/System/Releases/index.tsx b/frontend/src/pages/System/Releases/index.tsx
index 71cc7bd86d..ed4388e43e 100644
--- a/frontend/src/pages/System/Releases/index.tsx
+++ b/frontend/src/pages/System/Releases/index.tsx
@@ -1,13 +1,15 @@
-import { FunctionComponent, useMemo } from "react";
+import React, { FunctionComponent, useMemo } from "react";
import {
Badge,
Card,
+ Code,
Container,
Divider,
Group,
List,
Stack,
Text,
+ Title,
} from "@mantine/core";
import { useDocumentTitle } from "@mantine/hooks";
import { useSystemReleases } from "@/apis/hooks";
@@ -34,20 +36,111 @@ const SystemReleasesView: FunctionComponent = () => {
);
};
+function renderInlineMarkdown(text: string) {
+ const parts: (string | React.JSX.Element)[] = [];
+ const regex = /`([^`]+)`|\*\*([^*]+)\*\*|\[([^\]]+)\]\([^)]+\)/g;
+ let lastIndex = 0;
+ let match;
+
+ while ((match = regex.exec(text)) !== null) {
+ if (match.index > lastIndex) {
+ parts.push(text.slice(lastIndex, match.index));
+ }
+ if (match[1]) {
+ parts.push({match[1]});
+ } else if (match[2]) {
+ parts.push(
+
+ {match[2]}
+ ,
+ );
+ } else if (match[3]) {
+ parts.push(match[3]);
+ }
+ lastIndex = match.index + match[0].length;
+ }
+
+ if (lastIndex < text.length) {
+ parts.push(text.slice(lastIndex));
+ }
+
+ return parts;
+}
+
+function MarkdownBody({ text }: { text: string | string[] }) {
+ const elements = useMemo(() => {
+ const normalized = Array.isArray(text) ? text.join("\n") : text;
+ const lines = normalized.split("\n");
+ const result: React.JSX.Element[] = [];
+ let listItems: React.JSX.Element[] = [];
+ let listKey = 0;
+
+ const flushList = () => {
+ if (listItems.length > 0) {
+ result.push(
+
+ {listItems}
+
,
+ );
+ listItems = [];
+ }
+ };
+
+ lines.forEach((line, idx) => {
+ const trimmed = line.trim();
+
+ if (!trimmed) {
+ return;
+ }
+
+ if (trimmed.startsWith("### ")) {
+ flushList();
+ result.push(
+
+ {trimmed.slice(4)}
+ ,
+ );
+ } else if (trimmed.startsWith("## ")) {
+ flushList();
+ result.push(
+
+ {trimmed.slice(3)}
+ ,
+ );
+ } else if (/^[-*] /.test(trimmed)) {
+ const content = trimmed.replace(/^[-*] /, "");
+ listItems.push(
+ {renderInlineMarkdown(content)} ,
+ );
+ } else {
+ flushList();
+ result.push(
+
+ {renderInlineMarkdown(trimmed)}
+ ,
+ );
+ }
+ });
+
+ flushList();
+ return result;
+ }, [text]);
+
+ return <>{elements}>;
+}
+
const ReleaseCard: FunctionComponent = ({
name,
body,
date,
prerelease,
current,
+ repo,
}) => {
- const infos = useMemo(
- () => body.map((v) => v.replace(/(\s\[.*?\])\(.*?\)/, "")),
- [body],
- );
return (
-
+
+ {repo && {repo} }
{name}
{date}
@@ -56,12 +149,7 @@ const ReleaseCard: FunctionComponent = ({
{current && Installed }
- From newest to oldest:
-
- {infos.map((v, idx) => (
- {v}
- ))}
-
+
);
};
diff --git a/frontend/src/pages/System/Status/index.tsx b/frontend/src/pages/System/Status/index.tsx
index abbac7b36e..a24c88ce28 100644
--- a/frontend/src/pages/System/Status/index.tsx
+++ b/frontend/src/pages/System/Status/index.tsx
@@ -17,11 +17,7 @@ import {
} from "@mantine/core";
import { useDocumentTitle } from "@mantine/hooks";
import { IconDefinition } from "@fortawesome/fontawesome-common-types";
-import {
- faDiscord,
- faGithub,
- faWikipediaW,
-} from "@fortawesome/free-brands-svg-icons";
+import { faGithub, faWikipediaW } from "@fortawesome/free-brands-svg-icons";
import { faCode, faPaperPlane } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useSystemHealth, useSystemStatus } from "@/apis/hooks";
@@ -176,11 +172,6 @@ const SystemStatusView: FunctionComponent = () => {
Swagger UI
-
-
- Bazarr on Discord
-
-
diff --git a/frontend/src/pages/Wanted/Movies/index.tsx b/frontend/src/pages/Wanted/Movies/index.tsx
index 88a4eb90ad..2579033c70 100644
--- a/frontend/src/pages/Wanted/Movies/index.tsx
+++ b/frontend/src/pages/Wanted/Movies/index.tsx
@@ -16,6 +16,7 @@ import Language from "@/components/bazarr/Language";
import { WantedItem } from "@/components/forms/MassTranslateForm";
import WantedView from "@/pages/views/WantedView";
import { BuildKey } from "@/utilities";
+import tableStyles from "@/components/tables/BaseTable.module.scss";
const WantedMoviesView: FunctionComponent = () => {
const { download } = useMovieSubtitleModification();
@@ -115,7 +116,11 @@ const WantedMoviesView: FunctionComponent = () => {
}) => {
const target = `/movies/${radarrId}`;
return (
-
+
{title}
);
diff --git a/frontend/src/pages/Wanted/Series/index.tsx b/frontend/src/pages/Wanted/Series/index.tsx
index 917105317c..4363ac753b 100644
--- a/frontend/src/pages/Wanted/Series/index.tsx
+++ b/frontend/src/pages/Wanted/Series/index.tsx
@@ -16,6 +16,7 @@ import Language from "@/components/bazarr/Language";
import { WantedItem } from "@/components/forms/MassTranslateForm";
import WantedView from "@/pages/views/WantedView";
import { BuildKey } from "@/utilities";
+import tableStyles from "@/components/tables/BaseTable.module.scss";
const WantedSeriesView: FunctionComponent = () => {
const { download } = useEpisodeSubtitleModification();
@@ -115,7 +116,11 @@ const WantedSeriesView: FunctionComponent = () => {
}) => {
const target = `/series/${sonarrSeriesId}`;
return (
-
+
{seriesTitle}
);
@@ -135,9 +140,27 @@ const WantedSeriesView: FunctionComponent = () => {
{
header: "Episode",
accessorKey: "episode_number",
+ cell: ({
+ row: {
+ original: { episode_number: episodeNumber },
+ },
+ }) => {
+ return (
+ {episodeNumber}
+ );
+ },
},
{
accessorKey: "episodeTitle",
+ cell: ({
+ row: {
+ original: { episodeTitle },
+ },
+ }) => {
+ return (
+ {episodeTitle}
+ );
+ },
},
{
header: "Missing",
diff --git a/frontend/src/pages/views/ItemOverview.module.scss b/frontend/src/pages/views/ItemOverview.module.scss
new file mode 100644
index 0000000000..67783c5fba
--- /dev/null
+++ b/frontend/src/pages/views/ItemOverview.module.scss
@@ -0,0 +1,21 @@
+.fanartWrapper {
+ position: relative;
+ margin: 0 12px;
+ border-radius: var(--bz-radius-lg);
+ overflow: hidden;
+}
+
+.fanartOverlay {
+ background: rgba(0, 0, 0, 0.65);
+
+ // Force dark-style colors inside overlay regardless of theme
+ @include mantine.light {
+ color: #e8e8f0;
+
+ :global(.mantine-Badge-root[data-variant="light"]) {
+ background: rgba(255, 255, 255, 0.1);
+ color: rgba(255, 255, 255, 0.85);
+ border-color: rgba(255, 255, 255, 0.15);
+ }
+ }
+}
diff --git a/frontend/src/pages/views/ItemOverview.tsx b/frontend/src/pages/views/ItemOverview.tsx
index 5259c53c8c..2b8c5626a0 100644
--- a/frontend/src/pages/views/ItemOverview.tsx
+++ b/frontend/src/pages/views/ItemOverview.tsx
@@ -35,6 +35,7 @@ import {
useLanguageProfileBy,
useProfileItemsToLanguages,
} from "@/utilities/languages";
+import classes from "./ItemOverview.module.scss";
interface Props {
item: Item.Base | null;
@@ -137,68 +138,71 @@ const ItemOverview: FunctionComponent = (props) => {
}, [profile, profileItems]);
return (
-
-
-
-
-
-
-
-
-
-
-
-
-
- {item?.title}
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {item?.title}
-
-
-
- {item?.alternativeTitles.map((v, idx) => (
- {v}
- ))}
-
-
-
-
-
- {detailBadges}
-
-
- {audioBadges}
-
-
- {languageBadges}
-
-
- {item?.overview}
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+ {item?.alternativeTitles.map((v, idx) => (
+ {v}
+ ))}
+
+
+
+
+
+ {detailBadges}
+
+
+ {audioBadges}
+
+
+ {languageBadges}
+
+
+ {item?.overview}
+
+
+
+
+
+
);
};
diff --git a/frontend/src/pages/views/ItemView.tsx b/frontend/src/pages/views/ItemView.tsx
index 02a15ae65c..a424c11ead 100644
--- a/frontend/src/pages/views/ItemView.tsx
+++ b/frontend/src/pages/views/ItemView.tsx
@@ -1,5 +1,4 @@
-import { useCallback, useMemo, useState } from "react";
-import { useNavigate } from "react-router";
+import { ReactNode, useCallback, useMemo, useState } from "react";
import {
ActionIcon,
Badge,
@@ -19,14 +18,13 @@ import {
import {
faEraser,
faFilter,
- faList,
faSearch,
faTimes,
faVolumeUp,
faVolumeXmark,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
-import { ColumnDef } from "@tanstack/react-table";
+import { ColumnDef, Row } from "@tanstack/react-table";
import { useAudioLanguages } from "@/apis/hooks";
import { UsePaginationQueryResult } from "@/apis/queries/hooks";
import { QueryPageTable, Toolbox } from "@/components";
@@ -40,6 +38,10 @@ interface Props {
onAudioLanguagesChange?: (values: string[]) => void;
excludeLanguages?: string[];
onExcludeLanguagesChange?: (values: string[]) => void;
+ enableRowSelection?: boolean;
+ onSelectionChanged?: (selections: T[]) => void;
+ selectionToolbar?: ReactNode;
+ profileToolbar?: ReactNode;
}
function ItemView({
@@ -51,8 +53,11 @@ function ItemView({
onAudioLanguagesChange,
excludeLanguages = [],
onExcludeLanguagesChange,
+ enableRowSelection,
+ onSelectionChanged,
+ selectionToolbar,
+ profileToolbar,
}: Props) {
- const navigate = useNavigate();
const { data: audioLangs = [] } = useAudioLanguages();
const [filtersOpen, setFiltersOpen] = useState(false);
@@ -171,13 +176,10 @@ function ItemView({
return (
- navigate("edit")}
- >
- Mass Edit
-
+
+ {selectionToolbar ? {selectionToolbar} : null}
+ {profileToolbar ? {profileToolbar} : null}
+
{hasAnyFilterControl && (
({
label={activeFilterCount > 0 ? activeFilterCount : undefined}
size={16}
offset={4}
- color="blue"
+ color="brand"
disabled={activeFilterCount === 0}
>
0 ? "blue" : "gray"}
+ variant="gradient"
+ gradient={{ from: "brand.5", to: "brand.6", deg: 135 }}
size="lg"
onClick={() => setFiltersOpen((v) => !v)}
aria-label="Toggle filters"
+ style={{ opacity: filtersOpen ? 1 : 0.9 }}
>
@@ -241,11 +244,11 @@ function ItemView({
px="md"
py={8}
style={{
- borderBottom: "1px solid var(--mantine-color-default-border)",
+ borderBottom: "1px solid var(--bz-border-divider)",
}}
>
-
+
Active filters:
{activeFilterChips.map((chip) => (
@@ -281,7 +284,7 @@ function ItemView({
-
+
Clear all
@@ -297,8 +300,8 @@ function ItemView({
py="sm"
radius={0}
style={{
- borderBottom: "1px solid var(--mantine-color-default-border)",
- backgroundColor: "var(--mantine-color-body)",
+ borderBottom: "1px solid var(--bz-border-divider)",
+ backgroundColor: "var(--bz-surface-base)",
}}
>
@@ -306,7 +309,7 @@ function ItemView({
-
+
Include Audio Languages
@@ -336,7 +339,7 @@ function ItemView({
size="xs"
opacity={0.6}
/>
-
+
Exclude Audio Languages
@@ -366,6 +369,10 @@ function ItemView({
query={query}
dataFilter={hasActiveFilter ? dataFilter : undefined}
tableStyles={{ emptyText: "No items found" }}
+ enableRowSelection={enableRowSelection}
+ onRowSelectionChanged={(rows: Row[]) => {
+ onSelectionChanged?.(rows.map((r) => r.original));
+ }}
>
);
diff --git a/frontend/src/pages/views/MassEditor.tsx b/frontend/src/pages/views/MassEditor.tsx
deleted file mode 100644
index 57221888ab..0000000000
--- a/frontend/src/pages/views/MassEditor.tsx
+++ /dev/null
@@ -1,167 +0,0 @@
-import { useCallback, useMemo, useRef, useState } from "react";
-import { useNavigate } from "react-router";
-import { Box, Container, useCombobox } from "@mantine/core";
-import { faCheck, faUndo } from "@fortawesome/free-solid-svg-icons";
-import { UseMutationResult } from "@tanstack/react-query";
-import { ColumnDef, Table } from "@tanstack/react-table";
-import { uniqBy } from "lodash";
-import { useIsAnyMutationRunning, useLanguageProfiles } from "@/apis/hooks";
-import { GroupedSelector, GroupedSelectorOptions, Toolbox } from "@/components";
-import SimpleTable from "@/components/tables/SimpleTable";
-import { GetItemId, useSelectorOptions } from "@/utilities";
-
-interface MassEditorProps {
- columns: ColumnDef[];
- data: T[];
- mutation: UseMutationResult;
-}
-
-function MassEditor(props: MassEditorProps) {
- const { columns, data: raw, mutation } = props;
-
- const [selections, setSelections] = useState([]);
- const [dirties, setDirties] = useState([]);
- const hasTask = useIsAnyMutationRunning();
- const { data: profiles } = useLanguageProfiles();
- const tableRef = useRef>(null);
-
- const navigate = useNavigate();
-
- const onEnded = useCallback(() => navigate(".."), [navigate]);
-
- const data = useMemo(
- () => uniqBy([...dirties, ...(raw ?? [])], GetItemId),
- [dirties, raw],
- );
-
- const profileOptions = useSelectorOptions(profiles ?? [], (v) => v.name);
-
- const profileOptionsWithAction = useMemo<
- GroupedSelectorOptions[]
- >(() => {
- return [
- {
- group: "Actions",
- items: [{ label: "Clear", value: "", profileId: null }],
- },
- {
- group: "Profiles",
- items: profileOptions.options.map((a) => {
- return {
- value: a.value.profileId.toString(),
- label: a.label,
- profileId: a.value.profileId,
- };
- }),
- },
- ];
- }, [profileOptions.options]);
-
- const { mutateAsync } = mutation;
-
- /**
- * Submit the form that contains the series id and the respective profile id set in chunks to prevent payloads too
- * large when we have a high amount of series or movies being applied the profile. The chunks are executed in order
- * since there are no much benefit on executing in parallel, also parallelism could result in high load on the server
- * side if not throttled properly.
- */
- const save = useCallback(() => {
- const chunkSize = 1000;
-
- const form: FormType.ModifyItem = {
- id: [],
- profileid: [],
- };
-
- dirties.forEach((v) => {
- const id = GetItemId(v);
- if (id) {
- form.id.push(id);
- form.profileid.push(v.profileId);
- }
- });
-
- const mutateInChunks = async (
- ids: number[],
- profileIds: (number | null)[],
- ) => {
- if (ids.length === 0) return;
-
- const chunkIds = ids.slice(0, chunkSize);
- const chunkProfileIds = profileIds.slice(0, chunkSize);
-
- await mutateAsync({
- id: chunkIds,
- profileid: chunkProfileIds,
- });
-
- await mutateInChunks(ids.slice(chunkSize), profileIds.slice(chunkSize));
- };
-
- return mutateInChunks(form.id, form.profileid);
- }, [dirties, mutateAsync]);
-
- const setProfiles = useCallback(
- (id: number | null) => {
- const newItems = selections.map((v) => ({ ...v, profileId: id }));
-
- setDirties((dirty) => {
- return uniqBy([...newItems, ...dirty], GetItemId);
- });
-
- tableRef.current?.toggleAllRowsSelected(false);
- },
- [selections],
- );
-
- const combobox = useCombobox();
-
- return (
-
-
-
- combobox.openDropdown()}
- onDropdownClose={() => {
- combobox.resetSelectedOption();
- }}
- placeholder="Change Profile"
- withCheckIcon={false}
- options={profileOptionsWithAction}
- disabled={selections.length === 0}
- comboboxProps={{
- store: combobox,
- onOptionSubmit: (value) => {
- setProfiles(value ? +value : null);
- },
- }}
- >
-
-
-
- Cancel
-
-
- Save
-
-
-
- {
- setSelections(row.map((r) => r.original));
- }}
- >
-
- );
-}
-
-export default MassEditor;
diff --git a/frontend/src/pages/views/WantedView.tsx b/frontend/src/pages/views/WantedView.tsx
index 0ac45edcc5..eed3170ba1 100644
--- a/frontend/src/pages/views/WantedView.tsx
+++ b/frontend/src/pages/views/WantedView.tsx
@@ -229,15 +229,16 @@ function WantedView({
label={activeFilterCount > 0 ? activeFilterCount : undefined}
size={16}
offset={4}
- color="blue"
+ color="brand"
disabled={activeFilterCount === 0}
>
0 ? "blue" : "gray"}
+ variant="gradient"
+ gradient={{ from: "brand.5", to: "brand.6", deg: 135 }}
size="lg"
onClick={() => setFiltersOpen((v) => !v)}
aria-label="Toggle filters"
+ style={{ opacity: filtersOpen ? 1 : 0.9 }}
>
@@ -281,11 +282,11 @@ function WantedView({
px="md"
py={8}
style={{
- borderBottom: "1px solid var(--mantine-color-default-border)",
+ borderBottom: "1px solid var(--bz-border-divider)",
}}
>
-
+
Active filters:
{activeFilterChips.map((chip) => (
@@ -321,7 +322,7 @@ function WantedView({
-
+
Clear all
@@ -337,8 +338,8 @@ function WantedView({
py="sm"
radius={0}
style={{
- borderBottom: "1px solid var(--mantine-color-default-border)",
- backgroundColor: "var(--mantine-color-body)",
+ borderBottom: "1px solid var(--bz-border-divider)",
+ backgroundColor: "var(--bz-surface-base)",
}}
>
@@ -346,7 +347,7 @@ function WantedView({
-
+
Include Audio Languages
@@ -376,7 +377,7 @@ function WantedView({
size="xs"
opacity={0.6}
/>
-
+
Exclude Audio Languages
@@ -406,7 +407,7 @@ function WantedView({
size="xs"
opacity={0.6}
/>
-
+
Missing Subtitle Language
diff --git a/frontend/src/providers.tsx b/frontend/src/providers.tsx
index d1eb7b4342..0582463bd5 100644
--- a/frontend/src/providers.tsx
+++ b/frontend/src/providers.tsx
@@ -2,11 +2,11 @@ import { FunctionComponent, PropsWithChildren } from "react";
import { Notifications } from "@mantine/notifications";
import { QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
+import "@fontsource-variable/geist";
import queryClient from "@/apis/queries";
import ThemeProvider from "@/App/ThemeProvider";
import { ModalsProvider } from "@/modules/modals";
import { Environment } from "./utilities";
-import "@fontsource/roboto/300.css";
export const AllProviders: FunctionComponent = ({
children,
diff --git a/frontend/src/tests/setup.tsx b/frontend/src/tests/setup.tsx
index 52310b9066..45984c56c4 100644
--- a/frontend/src/tests/setup.tsx
+++ b/frontend/src/tests/setup.tsx
@@ -58,6 +58,15 @@ beforeEach(() => {
},
});
}),
+ http.get("/api/system/languages/audio", () => {
+ return HttpResponse.json([]);
+ }),
+ http.get("/api/system/languages/profiles", () => {
+ return HttpResponse.json([]);
+ }),
+ http.get("/api/subtitles/upgradable", () => {
+ return HttpResponse.json({ movies: [], series: [] });
+ }),
);
});
diff --git a/frontend/src/types/api.d.ts b/frontend/src/types/api.d.ts
index c1272eefd0..4725f5284e 100644
--- a/frontend/src/types/api.d.ts
+++ b/frontend/src/types/api.d.ts
@@ -269,6 +269,7 @@ declare namespace Plex {
pinId: string;
code: string;
clientId: string;
+ state: string;
authUrl: string;
}
@@ -388,7 +389,8 @@ interface ReleaseInfo {
date: string;
name: string;
prerelease: boolean;
- body: string[];
+ body: string | string[];
+ repo?: string;
}
interface SubtitleInfo {
@@ -397,6 +399,27 @@ interface SubtitleInfo {
season: number;
}
+declare namespace SubtitleContents {
+ interface LineTime {
+ hours: number;
+ minutes: number;
+ seconds: number;
+ total_seconds: number;
+ microseconds: number;
+ }
+
+ interface Line {
+ index: number;
+ content: string;
+ proprietary: string;
+ start: LineTime;
+ end: LineTime;
+ // duration: LineTime;
+ }
+
+ // interface Contents extends Array {}
+}
+
type ItemSearchResult = Partial &
Partial & {
title: string;
diff --git a/frontend/src/types/react-table.d.ts b/frontend/src/types/react-table.d.ts
index c05bcd9fd1..775d06537f 100644
--- a/frontend/src/types/react-table.d.ts
+++ b/frontend/src/types/react-table.d.ts
@@ -42,11 +42,13 @@ declare module "react-table" {
}
// eslint-disable-next-line @typescript-eslint/no-empty-interface
- interface CustomTableProps>
- extends useSelectionProps {}
+ interface CustomTableProps<
+ D extends Record,
+ > extends useSelectionProps {}
export interface TableOptions>
- extends UseExpandedOptions,
+ extends
+ UseExpandedOptions,
// UseFiltersOptions,
// UseGlobalFiltersOptions,
UseGroupByOptions,
@@ -61,14 +63,18 @@ declare module "react-table" {
export interface Hooks<
D extends Record = Record,
- > extends UseExpandedHooks,
+ >
+ extends
+ UseExpandedHooks,
UseGroupByHooks,
UseRowSelectHooks,
UseSortByHooks {}
export interface TableInstance<
D extends Record = Record,
- > extends UseColumnOrderInstanceProps,
+ >
+ extends
+ UseColumnOrderInstanceProps,
UseExpandedInstanceProps,
// UseFiltersInstanceProps,
// UseGlobalFiltersInstanceProps,
@@ -81,7 +87,9 @@ declare module "react-table" {
export interface TableState<
D extends Record = Record,
- > extends UseColumnOrderState,
+ >
+ extends
+ UseColumnOrderState,
UseExpandedState,
// UseFiltersState,
// UseGlobalFiltersState,
@@ -94,7 +102,9 @@ declare module "react-table" {
export interface ColumnInterface<
D extends Record = Record,
- > extends UseFiltersColumnOptions,
+ >
+ extends
+ UseFiltersColumnOptions,
// UseGlobalFiltersColumnOptions,
UseGroupByColumnOptions,
// UseResizeColumnsColumnOptions,
@@ -102,7 +112,9 @@ declare module "react-table" {
export interface ColumnInstance<
D extends Record = Record,
- > extends UseFiltersColumnProps,
+ >
+ extends
+ UseFiltersColumnProps,
UseGroupByColumnProps,
// UseResizeColumnsColumnProps,
UseSortByColumnProps {}
@@ -114,7 +126,9 @@ declare module "react-table" {
export interface Row<
D extends Record = Record,
- > extends UseExpandedRowProps,
+ >
+ extends
+ UseExpandedRowProps,
UseGroupByRowProps,
UseRowSelectRowProps {}
}
diff --git a/frontend/src/types/settings.d.ts b/frontend/src/types/settings.d.ts
index e8ef0916bc..8a6c7c3c9c 100644
--- a/frontend/src/types/settings.d.ts
+++ b/frontend/src/types/settings.d.ts
@@ -80,6 +80,7 @@ declare namespace Settings {
use_scenename: boolean;
use_sonarr: boolean;
utf8_encode: boolean;
+ provider_priorities?: string;
wanted_search_frequency: number;
wanted_search_frequency_movie: number;
use_external_webhook?: boolean;
@@ -176,8 +177,9 @@ declare namespace Settings {
interface Translator {
default_score: number;
- gemini_key: string;
+ gemini_keys: string[];
gemini_model: string;
+ gemini_batch_size: number;
lingarr_url: string;
lingarr_token: string;
translator_info: boolean;
diff --git a/frontend/src/utilities/routers.ts b/frontend/src/utilities/routers.ts
deleted file mode 100644
index 397e72c9cb..0000000000
--- a/frontend/src/utilities/routers.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-// Navigation blocker utility using React Router's useBlocker with Mantine confirmation modal
-
-import { useEffect, useRef } from "react";
-import { useBlocker } from "react-router";
-import { modals } from "@mantine/modals";
-
-export function usePrompt(when: boolean, message: string) {
- const blocker = useBlocker(
- ({ currentLocation, nextLocation }) =>
- when && currentLocation.pathname !== nextLocation.pathname,
- );
-
- const prevWhen = useRef(when);
-
- useEffect(() => {
- if (blocker.state === "blocked" && prevWhen.current === when) {
- modals.openConfirmModal({
- title: "Unsaved Changes",
- children: message,
- labels: { confirm: "Leave", cancel: "Stay" },
- confirmProps: { color: "red" },
- onConfirm: () => blocker.proceed(),
- onCancel: () => blocker.reset(),
- closeOnCancel: true,
- closeOnConfirm: true,
- });
- }
- prevWhen.current = when;
- }, [blocker, message, when]);
-}
diff --git a/frontend/src/utilities/routers.tsx b/frontend/src/utilities/routers.tsx
new file mode 100644
index 0000000000..834d88854e
--- /dev/null
+++ b/frontend/src/utilities/routers.tsx
@@ -0,0 +1,109 @@
+import { useCallback, useEffect, useRef } from "react";
+import { useBlocker } from "react-router";
+import { Button, Group, Stack, Text } from "@mantine/core";
+import { modals } from "@mantine/modals";
+
+export function usePrompt(
+ when: boolean,
+ message: string,
+ onSaveAndLeave?: () => Promise | void,
+) {
+ const blocker = useBlocker(
+ ({ currentLocation, nextLocation }) =>
+ when && currentLocation.pathname !== nextLocation.pathname,
+ );
+
+ const prevWhen = useRef(when);
+ const previousFocus = useRef(null);
+
+ const handleStay = useCallback(() => {
+ modals.closeAll();
+ if (blocker.state === "blocked") {
+ blocker.reset?.();
+ }
+ requestAnimationFrame(() => previousFocus.current?.focus());
+ }, [blocker]);
+
+ const handleDiscard = useCallback(() => {
+ modals.closeAll();
+ if (blocker.state === "blocked") {
+ blocker.proceed?.();
+ }
+ }, [blocker]);
+
+ const handleSaveAndLeave = useCallback(async () => {
+ if (onSaveAndLeave) {
+ await onSaveAndLeave();
+ }
+ modals.closeAll();
+ if (blocker.state === "blocked") {
+ blocker.proceed?.();
+ }
+ }, [blocker, onSaveAndLeave]);
+
+ useEffect(() => {
+ if (blocker.state === "blocked" && prevWhen.current === when) {
+ previousFocus.current = document.activeElement as HTMLElement;
+
+ modals.open({
+ title: "Unsaved Changes",
+ centered: true,
+ size: "md",
+ closeOnEscape: true,
+ closeOnClickOutside: false,
+ onClose: () => {
+ if (blocker.state === "blocked") {
+ blocker.reset?.();
+ }
+ requestAnimationFrame(() => previousFocus.current?.focus());
+ },
+ children: (
+
+
+ {message}
+
+
+
+ Keep Editing
+
+
+ Discard
+
+ {onSaveAndLeave && (
+
+ Save & Leave
+
+ )}
+
+
+ ),
+ });
+ }
+ prevWhen.current = when;
+ }, [
+ blocker,
+ message,
+ when,
+ handleStay,
+ handleDiscard,
+ handleSaveAndLeave,
+ onSaveAndLeave,
+ ]);
+}
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index 4f54b9f33a..ee6f35b559 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -30,9 +30,6 @@ export default defineConfig(({ mode, command }) => {
react(),
checker({
typescript: true,
- eslint: {
- lintCommand: "eslint --ext .ts,.tsx src",
- },
enableBuild: false,
}),
VitePWA({
@@ -50,7 +47,7 @@ export default defineConfig(({ mode, command }) => {
short_name: "Bazarr",
description:
"Bazarr is a companion application to Sonarr and Radarr. It manages and downloads subtitles based on your requirements.",
- theme_color: "#be4bdb",
+ theme_color: "#b36b00",
icons: [
{
src: `${imagesFolder}/pwa-64x64.png`,
@@ -136,6 +133,8 @@ export default defineConfig(({ mode, command }) => {
globals: true,
environment: "jsdom",
setupFiles: "./src/tests/setup.tsx",
+ testTimeout: 20000,
+ pool: "forks",
},
server: {
proxy: {
diff --git a/libs/APScheduler-3.10.4.dist-info/METADATA b/libs/APScheduler-3.10.4.dist-info/METADATA
deleted file mode 100644
index 62df97e735..0000000000
--- a/libs/APScheduler-3.10.4.dist-info/METADATA
+++ /dev/null
@@ -1,138 +0,0 @@
-Metadata-Version: 2.1
-Name: APScheduler
-Version: 3.10.4
-Summary: In-process task scheduler with Cron-like capabilities
-Home-page: https://github.com/agronholm/apscheduler
-Author: Alex Grรถnholm
-Author-email: apscheduler@nextday.fi
-License: MIT
-Keywords: scheduling cron
-Classifier: Development Status :: 5 - Production/Stable
-Classifier: Intended Audience :: Developers
-Classifier: License :: OSI Approved :: MIT License
-Classifier: Programming Language :: Python
-Classifier: Programming Language :: Python :: 3
-Classifier: Programming Language :: Python :: 3.6
-Classifier: Programming Language :: Python :: 3.7
-Classifier: Programming Language :: Python :: 3.8
-Classifier: Programming Language :: Python :: 3.9
-Classifier: Programming Language :: Python :: 3.10
-Classifier: Programming Language :: Python :: 3.11
-Requires-Python: >=3.6
-License-File: LICENSE.txt
-Requires-Dist: six >=1.4.0
-Requires-Dist: pytz
-Requires-Dist: tzlocal !=3.*,>=2.0
-Requires-Dist: importlib-metadata >=3.6.0 ; python_version < "3.8"
-Provides-Extra: doc
-Requires-Dist: sphinx ; extra == 'doc'
-Requires-Dist: sphinx-rtd-theme ; extra == 'doc'
-Provides-Extra: gevent
-Requires-Dist: gevent ; extra == 'gevent'
-Provides-Extra: mongodb
-Requires-Dist: pymongo >=3.0 ; extra == 'mongodb'
-Provides-Extra: redis
-Requires-Dist: redis >=3.0 ; extra == 'redis'
-Provides-Extra: rethinkdb
-Requires-Dist: rethinkdb >=2.4.0 ; extra == 'rethinkdb'
-Provides-Extra: sqlalchemy
-Requires-Dist: sqlalchemy >=1.4 ; extra == 'sqlalchemy'
-Provides-Extra: testing
-Requires-Dist: pytest ; extra == 'testing'
-Requires-Dist: pytest-asyncio ; extra == 'testing'
-Requires-Dist: pytest-cov ; extra == 'testing'
-Requires-Dist: pytest-tornado5 ; extra == 'testing'
-Provides-Extra: tornado
-Requires-Dist: tornado >=4.3 ; extra == 'tornado'
-Provides-Extra: twisted
-Requires-Dist: twisted ; extra == 'twisted'
-Provides-Extra: zookeeper
-Requires-Dist: kazoo ; extra == 'zookeeper'
-
-.. image:: https://github.com/agronholm/apscheduler/workflows/Python%20codeqa/test/badge.svg?branch=3.x
- :target: https://github.com/agronholm/apscheduler/actions?query=workflow%3A%22Python+codeqa%2Ftest%22+branch%3A3.x
- :alt: Build Status
-.. image:: https://coveralls.io/repos/github/agronholm/apscheduler/badge.svg?branch=3.x
- :target: https://coveralls.io/github/agronholm/apscheduler?branch=3.x
- :alt: Code Coverage
-.. image:: https://readthedocs.org/projects/apscheduler/badge/?version=3.x
- :target: https://apscheduler.readthedocs.io/en/master/?badge=3.x
- :alt: Documentation
-
-Advanced Python Scheduler (APScheduler) is a Python library that lets you schedule your Python code
-to be executed later, either just once or periodically. You can add new jobs or remove old ones on
-the fly as you please. If you store your jobs in a database, they will also survive scheduler
-restarts and maintain their state. When the scheduler is restarted, it will then run all the jobs
-it should have run while it was offline [#f1]_.
-
-Among other things, APScheduler can be used as a cross-platform, application specific replacement
-to platform specific schedulers, such as the cron daemon or the Windows task scheduler. Please
-note, however, that APScheduler is **not** a daemon or service itself, nor does it come with any
-command line tools. It is primarily meant to be run inside existing applications. That said,
-APScheduler does provide some building blocks for you to build a scheduler service or to run a
-dedicated scheduler process.
-
-APScheduler has three built-in scheduling systems you can use:
-
-* Cron-style scheduling (with optional start/end times)
-* Interval-based execution (runs jobs on even intervals, with optional start/end times)
-* One-off delayed execution (runs jobs once, on a set date/time)
-
-You can mix and match scheduling systems and the backends where the jobs are stored any way you
-like. Supported backends for storing jobs include:
-
-* Memory
-* `SQLAlchemy `_ (any RDBMS supported by SQLAlchemy works)
-* `MongoDB `_
-* `Redis `_
-* `RethinkDB `_
-* `ZooKeeper `_
-
-APScheduler also integrates with several common Python frameworks, like:
-
-* `asyncio `_ (:pep:`3156`)
-* `gevent `_
-* `Tornado `_
-* `Twisted `_
-* `Qt `_ (using either
- `PyQt `_ ,
- `PySide6 `_ ,
- `PySide2 `_ or
- `PySide `_)
-
-There are third party solutions for integrating APScheduler with other frameworks:
-
-* `Django `_
-* `Flask `_
-
-
-.. [#f1] The cutoff period for this is also configurable.
-
-
-Documentation
--------------
-
-Documentation can be found `here `_.
-
-
-Source
-------
-
-The source can be browsed at `Github `_.
-
-
-Reporting bugs
---------------
-
-A `bug tracker `_ is provided by Github.
-
-
-Getting help
-------------
-
-If you have problems or other questions, you can either:
-
-* Ask in the `apscheduler `_ room on Gitter
-* Ask on the `APScheduler GitHub discussion forum `_, or
-* Ask on `StackOverflow `_ and tag your
- question with the ``apscheduler`` tag
diff --git a/libs/APScheduler-3.10.4.dist-info/RECORD b/libs/APScheduler-3.10.4.dist-info/RECORD
deleted file mode 100644
index 6a44be9239..0000000000
--- a/libs/APScheduler-3.10.4.dist-info/RECORD
+++ /dev/null
@@ -1,46 +0,0 @@
-APScheduler-3.10.4.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
-APScheduler-3.10.4.dist-info/LICENSE.txt,sha256=YWP3mH37ONa8MgzitwsvArhivEESZRbVUu8c1DJH51g,1130
-APScheduler-3.10.4.dist-info/METADATA,sha256=ITYjDYv8SBO2ynuPiXmySCDJPjfvrFElLJoKQr58h8U,5695
-APScheduler-3.10.4.dist-info/RECORD,,
-APScheduler-3.10.4.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
-APScheduler-3.10.4.dist-info/WHEEL,sha256=Xo9-1PvkuimrydujYJAjF7pCkriuXBpUPEjma1nZyJ0,92
-APScheduler-3.10.4.dist-info/entry_points.txt,sha256=KMxTUp2QykDNL6w-WBU5xrk8ebroCPEBN0eZtyL3x2w,1147
-APScheduler-3.10.4.dist-info/top_level.txt,sha256=O3oMCWxG-AHkecUoO6Ze7-yYjWrttL95uHO8-RFdYvE,12
-apscheduler/__init__.py,sha256=c_KXMg1QziacYqUpDuzLY5g1mcEZvBLq1dJY7NjLoKc,452
-apscheduler/events.py,sha256=KRMTDQUS6d2uVnrQvPoz3ZPV5V9XKsCAZLsgx913FFo,3593
-apscheduler/executors/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
-apscheduler/executors/asyncio.py,sha256=9m4wvRHSSYplllxAQyxWkPVcFdyFG5aZbHt5nfWKIAc,1859
-apscheduler/executors/base.py,sha256=hogiMc_t-huw6BMod0HEeY2FhRNmAAUyNNuBHvIX31M,5336
-apscheduler/executors/base_py3.py,sha256=8WOpTeX1NA-spdbEQ1oJMh5T2O_t2UdsaSnAh-iEWe0,1831
-apscheduler/executors/debug.py,sha256=15_ogSBzl8RRCfBYDnkIV2uMH8cLk1KImYmBa_NVGpc,573
-apscheduler/executors/gevent.py,sha256=aulrNmoefyBgrOkH9awRhFiXIDnSCnZ4U0o0_JXIXgc,777
-apscheduler/executors/pool.py,sha256=h4cYgKMRhjpNHmkhlogHLbmT4O_q6HePXVLmiJIHC3c,2484
-apscheduler/executors/tornado.py,sha256=DU75VaQ9R6nBuy8lbPUvDKUgsuJcZqwAvURC5vg3r6w,1780
-apscheduler/executors/twisted.py,sha256=bRoU0C4BoVcS6_BjKD5wfUs0IJpGkmLsRAcMH2rJJss,778
-apscheduler/job.py,sha256=JCRERBpfWLuomPiNNHX-jrluEwfHkdscEmz4i0Y8rao,11216
-apscheduler/jobstores/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
-apscheduler/jobstores/base.py,sha256=DXzSW9XscueHZHMvy1qFiG-vYqUl_MMv0n0uBSZWXGo,4523
-apscheduler/jobstores/memory.py,sha256=ZxWiKsqfsCHFvac-6X9BztuhnuSxlOYi1dhT6g-pjQo,3655
-apscheduler/jobstores/mongodb.py,sha256=r9t2neNuzfPuf_omDm0KdkLGPZXLksiH-U3j13MIBlM,5347
-apscheduler/jobstores/redis.py,sha256=kjQDIzPXz-Yq976U9HK3aMkcCI_QRLKgTADQWKewtik,5483
-apscheduler/jobstores/rethinkdb.py,sha256=k1rSLYJqejuhQxJY3pXwHAQYcpZ1QFJsoQ8n0oEu5MM,5863
-apscheduler/jobstores/sqlalchemy.py,sha256=LIA9iSGMvuPTVqGHdztgQs4YFmYN1xqXvpJauYNK470,6529
-apscheduler/jobstores/zookeeper.py,sha256=avGLXaJGjHD0F7uG6rLJ2gg_TXNqXDEM4PqOu56f-Xg,6363
-apscheduler/schedulers/__init__.py,sha256=jM63xA_K7GSToBenhsz-SCcqfhk1pdEVb6ajwoO5Kqg,406
-apscheduler/schedulers/asyncio.py,sha256=iJO6QUo1oW16giOU_nW8WMu2b9NTWT4Tg2gY586G08w,1994
-apscheduler/schedulers/background.py,sha256=751p-f5Di6pY4x6UXlZggpxQ5k2ObJ_Q5wSeWmKHS8o,1566
-apscheduler/schedulers/base.py,sha256=hCchDyhEXCoVmCfGgD3QMrKumYYLAUwY4456tQrukAY,43780
-apscheduler/schedulers/blocking.py,sha256=8nubfJ4PoUnAkEY6WRQG4COzG4SxGyW9PjuVPhDAbsk,985
-apscheduler/schedulers/gevent.py,sha256=csPBvV75FGcboXXsdex6fCD7J54QgBddYNdWj62ZO9g,1031
-apscheduler/schedulers/qt.py,sha256=jy58cP5roWOv68ytg8fiwtxMVnZKw7a8tkCHbLWeUs8,1329
-apscheduler/schedulers/tornado.py,sha256=D9Vaq3Ee9EFiXa1jDy9tedI048gR_YT_LAFUWqO_uEw,1926
-apscheduler/schedulers/twisted.py,sha256=D5EBjjMRtMBxy0_aAURcULAI8Ky2IvCTr9tK9sO1rYk,1844
-apscheduler/triggers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
-apscheduler/triggers/base.py,sha256=BvBJdOnIeVClXPXeInzYK25cN64jAc4a9IiEQucSiVk,1355
-apscheduler/triggers/combining.py,sha256=klaSoBp1kyrPX5D3gBpNTlsGKjks5QeKPW5JN_MVs30,3449
-apscheduler/triggers/cron/__init__.py,sha256=D39BQ63qWyk6XZcSuWth46ELQ3VIFpYjUHh7Kj65Z9M,9251
-apscheduler/triggers/cron/expressions.py,sha256=hu1kq0mKvivIw7U0D0Nnrbuk3q01dCuhZ7SHRPw6qhI,9184
-apscheduler/triggers/cron/fields.py,sha256=NWPClh1NgSOpTlJ3sm1TXM_ViC2qJGKWkd_vg0xsw7o,3510
-apscheduler/triggers/date.py,sha256=RrfB1PNO9G9e91p1BOf-y_TseVHQQR-KJPhNdPpAHcU,1705
-apscheduler/triggers/interval.py,sha256=ABjcZFaGYAAgdAaUQIuLr9_dLszIifu88qaXrJmdxQ4,4377
-apscheduler/util.py,sha256=aCLu_v8-c7rpY6sD7EKgxH2zYjZARiBdqKFZktaxO68,13260
diff --git a/libs/APScheduler-3.10.4.dist-info/WHEEL b/libs/APScheduler-3.10.4.dist-info/WHEEL
deleted file mode 100644
index ba48cbcf92..0000000000
--- a/libs/APScheduler-3.10.4.dist-info/WHEEL
+++ /dev/null
@@ -1,5 +0,0 @@
-Wheel-Version: 1.0
-Generator: bdist_wheel (0.41.3)
-Root-Is-Purelib: true
-Tag: py3-none-any
-
diff --git a/libs/APScheduler-3.10.4.dist-info/entry_points.txt b/libs/APScheduler-3.10.4.dist-info/entry_points.txt
deleted file mode 100644
index 0adfe3ead0..0000000000
--- a/libs/APScheduler-3.10.4.dist-info/entry_points.txt
+++ /dev/null
@@ -1,23 +0,0 @@
-[apscheduler.executors]
-asyncio = apscheduler.executors.asyncio:AsyncIOExecutor [asyncio]
-debug = apscheduler.executors.debug:DebugExecutor
-gevent = apscheduler.executors.gevent:GeventExecutor [gevent]
-processpool = apscheduler.executors.pool:ProcessPoolExecutor
-threadpool = apscheduler.executors.pool:ThreadPoolExecutor
-tornado = apscheduler.executors.tornado:TornadoExecutor [tornado]
-twisted = apscheduler.executors.twisted:TwistedExecutor [twisted]
-
-[apscheduler.jobstores]
-memory = apscheduler.jobstores.memory:MemoryJobStore
-mongodb = apscheduler.jobstores.mongodb:MongoDBJobStore [mongodb]
-redis = apscheduler.jobstores.redis:RedisJobStore [redis]
-rethinkdb = apscheduler.jobstores.rethinkdb:RethinkDBJobStore [rethinkdb]
-sqlalchemy = apscheduler.jobstores.sqlalchemy:SQLAlchemyJobStore [sqlalchemy]
-zookeeper = apscheduler.jobstores.zookeeper:ZooKeeperJobStore [zookeeper]
-
-[apscheduler.triggers]
-and = apscheduler.triggers.combining:AndTrigger
-cron = apscheduler.triggers.cron:CronTrigger
-date = apscheduler.triggers.date:DateTrigger
-interval = apscheduler.triggers.interval:IntervalTrigger
-or = apscheduler.triggers.combining:OrTrigger
diff --git a/libs/Flask_Cors-5.0.0.dist-info/LICENSE b/libs/Flask_Cors-5.0.0.dist-info/LICENSE
deleted file mode 100644
index 46d932f8d8..0000000000
--- a/libs/Flask_Cors-5.0.0.dist-info/LICENSE
+++ /dev/null
@@ -1,7 +0,0 @@
-Copyright (C) 2016 Cory Dolphin, Olin College
-
-Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
\ No newline at end of file
diff --git a/libs/Flask_Cors-5.0.0.dist-info/METADATA b/libs/Flask_Cors-5.0.0.dist-info/METADATA
deleted file mode 100644
index 99902fe9d6..0000000000
--- a/libs/Flask_Cors-5.0.0.dist-info/METADATA
+++ /dev/null
@@ -1,148 +0,0 @@
-Metadata-Version: 2.1
-Name: Flask-Cors
-Version: 5.0.0
-Summary: A Flask extension adding a decorator for CORS support
-Home-page: https://github.com/corydolphin/flask-cors
-Author: Cory Dolphin
-Author-email: corydolphin@gmail.com
-License: MIT
-Platform: any
-Classifier: Environment :: Web Environment
-Classifier: Intended Audience :: Developers
-Classifier: License :: OSI Approved :: MIT License
-Classifier: Operating System :: OS Independent
-Classifier: Programming Language :: Python
-Classifier: Programming Language :: Python :: 3.8
-Classifier: Programming Language :: Python :: 3.9
-Classifier: Programming Language :: Python :: 3.10
-Classifier: Programming Language :: Python :: 3.11
-Classifier: Programming Language :: Python :: 3.12
-Classifier: Programming Language :: Python :: Implementation :: CPython
-Classifier: Programming Language :: Python :: Implementation :: PyPy
-Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
-Classifier: Topic :: Software Development :: Libraries :: Python Modules
-License-File: LICENSE
-Requires-Dist: Flask >=0.9
-
-Flask-CORS
-==========
-
-|Build Status| |Latest Version| |Supported Python versions|
-|License|
-
-A Flask extension for handling Cross Origin Resource Sharing (CORS), making cross-origin AJAX possible.
-
-This package has a simple philosophy: when you want to enable CORS, you wish to enable it for all use cases on a domain.
-This means no mucking around with different allowed headers, methods, etc.
-
-By default, submission of cookies across domains is disabled due to the security implications.
-Please see the documentation for how to enable credential'ed requests, and please make sure you add some sort of `CSRF `__ protection before doing so!
-
-Installation
-------------
-
-Install the extension with using pip, or easy\_install.
-
-.. code:: bash
-
- $ pip install -U flask-cors
-
-Usage
------
-
-This package exposes a Flask extension which by default enables CORS support on all routes, for all origins and methods.
-It allows parameterization of all CORS headers on a per-resource level.
-The package also contains a decorator, for those who prefer this approach.
-
-Simple Usage
-~~~~~~~~~~~~
-
-In the simplest case, initialize the Flask-Cors extension with default arguments in order to allow CORS for all domains on all routes.
-See the full list of options in the `documentation `__.
-
-.. code:: python
-
-
- from flask import Flask
- from flask_cors import CORS
-
- app = Flask(__name__)
- CORS(app)
-
- @app.route("/")
- def helloWorld():
- return "Hello, cross-origin-world!"
-
-Resource specific CORS
-^^^^^^^^^^^^^^^^^^^^^^
-
-Alternatively, you can specify CORS options on a resource and origin level of granularity by passing a dictionary as the `resources` option, mapping paths to a set of options.
-See the full list of options in the `documentation `__.
-
-.. code:: python
-
- app = Flask(__name__)
- cors = CORS(app, resources={r"/api/*": {"origins": "*"}})
-
- @app.route("/api/v1/users")
- def list_users():
- return "user example"
-
-Route specific CORS via decorator
-^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
-
-This extension also exposes a simple decorator to decorate flask routes with.
-Simply add ``@cross_origin()`` below a call to Flask's ``@app.route(..)`` to allow CORS on a given route.
-See the full list of options in the `decorator documentation `__.
-
-.. code:: python
-
- @app.route("/")
- @cross_origin()
- def helloWorld():
- return "Hello, cross-origin-world!"
-
-Documentation
--------------
-
-For a full list of options, please see the full `documentation `__
-
-Troubleshooting
----------------
-
-If things aren't working as you expect, enable logging to help understand what is going on under the hood, and why.
-
-.. code:: python
-
- logging.getLogger('flask_cors').level = logging.DEBUG
-
-
-Tests
------
-
-A simple set of tests is included in ``test/``.
-To run, install nose, and simply invoke ``nosetests`` or ``python setup.py test`` to exercise the tests.
-
-If nosetests does not work for you, due to it no longer working with newer python versions.
-You can use pytest to run the tests instead.
-
-Contributing
-------------
-
-Questions, comments or improvements?
-Please create an issue on `Github `__, tweet at `@corydolphin `__ or send me an email.
-I do my best to include every contribution proposed in any way that I can.
-
-Credits
--------
-
-This Flask extension is based upon the `Decorator for the HTTP Access Control `__ written by Armin Ronacher.
-
-.. |Build Status| image:: https://github.com/corydolphin/flask-cors/actions/workflows/unittests.yaml/badge.svg
- :target: https://travis-ci.org/corydolphin/flask-cors
-.. |Latest Version| image:: https://img.shields.io/pypi/v/Flask-Cors.svg
- :target: https://pypi.python.org/pypi/Flask-Cors/
-.. |Supported Python versions| image:: https://img.shields.io/pypi/pyversions/Flask-Cors.svg
- :target: https://img.shields.io/pypi/pyversions/Flask-Cors.svg
-.. |License| image:: http://img.shields.io/:license-mit-blue.svg
- :target: https://pypi.python.org/pypi/Flask-Cors/
diff --git a/libs/Flask_Cors-5.0.0.dist-info/RECORD b/libs/Flask_Cors-5.0.0.dist-info/RECORD
deleted file mode 100644
index 5e942ce533..0000000000
--- a/libs/Flask_Cors-5.0.0.dist-info/RECORD
+++ /dev/null
@@ -1,12 +0,0 @@
-Flask_Cors-5.0.0.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
-Flask_Cors-5.0.0.dist-info/LICENSE,sha256=bhob3FSDTB4HQMvOXV9vLK4chG_Sp_SCsRZJWU-vvV0,1069
-Flask_Cors-5.0.0.dist-info/METADATA,sha256=V2L_s849dFlZXsOhcgXVqv5Slj_JKSVuiiuRgDOft5s,5474
-Flask_Cors-5.0.0.dist-info/RECORD,,
-Flask_Cors-5.0.0.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
-Flask_Cors-5.0.0.dist-info/WHEEL,sha256=P2T-6epvtXQ2cBOE_U1K4_noqlJFN3tj15djMgEu4NM,110
-Flask_Cors-5.0.0.dist-info/top_level.txt,sha256=aWye_0QNZPp_QtPF4ZluLHqnyVLT9CPJsfiGhwqkWuo,11
-flask_cors/__init__.py,sha256=wZDCvPTHspA2g1VV7KyKN7R-uCdBnirTlsCzgPDcQtI,792
-flask_cors/core.py,sha256=y76xxLasWTdV_3ka19IxpdJPOgROBZQZ5L8t20IjqRA,14252
-flask_cors/decorator.py,sha256=BeJsyX1wYhVKWN04FAhb6z8YqffiRr7wKqwzHPap4bw,5009
-flask_cors/extension.py,sha256=gzv6zWUwSDYlGHBWzMuTI_hoQ7gQmp9DlcAcrKTVHdw,8602
-flask_cors/version.py,sha256=JzYPYpvaglqIJRGCDrh5-hYmXI0ISrDDed0V1QQZAGU,22
diff --git a/libs/Flask_Cors-5.0.0.dist-info/WHEEL b/libs/Flask_Cors-5.0.0.dist-info/WHEEL
deleted file mode 100644
index f31e450fda..0000000000
--- a/libs/Flask_Cors-5.0.0.dist-info/WHEEL
+++ /dev/null
@@ -1,6 +0,0 @@
-Wheel-Version: 1.0
-Generator: bdist_wheel (0.41.3)
-Root-Is-Purelib: true
-Tag: py2-none-any
-Tag: py3-none-any
-
diff --git a/libs/Flask_Migrate-4.1.0.dist-info/METADATA b/libs/Flask_Migrate-4.1.0.dist-info/METADATA
index ef8ede367d..0b89e51cd3 100644
--- a/libs/Flask_Migrate-4.1.0.dist-info/METADATA
+++ b/libs/Flask_Migrate-4.1.0.dist-info/METADATA
@@ -1,4 +1,4 @@
-Metadata-Version: 2.1
+Metadata-Version: 2.4
Name: Flask-Migrate
Version: 4.1.0
Summary: SQLAlchemy database migrations for Flask applications using Alembic.
@@ -14,15 +14,16 @@ Classifier: Operating System :: OS Independent
Requires-Python: >=3.6
Description-Content-Type: text/markdown
License-File: LICENSE
-Requires-Dist: Flask >=0.9
-Requires-Dist: Flask-SQLAlchemy >=1.0
-Requires-Dist: alembic >=1.9.0
+Requires-Dist: Flask>=0.9
+Requires-Dist: Flask-SQLAlchemy>=1.0
+Requires-Dist: alembic>=1.9.0
Provides-Extra: dev
-Requires-Dist: tox ; extra == 'dev'
-Requires-Dist: flake8 ; extra == 'dev'
-Requires-Dist: pytest ; extra == 'dev'
+Requires-Dist: tox; extra == "dev"
+Requires-Dist: flake8; extra == "dev"
+Requires-Dist: pytest; extra == "dev"
Provides-Extra: docs
-Requires-Dist: sphinx ; extra == 'docs'
+Requires-Dist: sphinx; extra == "docs"
+Dynamic: license-file
Flask-Migrate
=============
diff --git a/libs/Flask_Migrate-4.1.0.dist-info/RECORD b/libs/Flask_Migrate-4.1.0.dist-info/RECORD
index d634fa09ab..3514c51736 100644
--- a/libs/Flask_Migrate-4.1.0.dist-info/RECORD
+++ b/libs/Flask_Migrate-4.1.0.dist-info/RECORD
@@ -1,10 +1,10 @@
-Flask_Migrate-4.1.0.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
-Flask_Migrate-4.1.0.dist-info/LICENSE,sha256=kfkXGlJQvKy3Y__6tAJ8ynIp1HQfeROXhL8jZU1d-DI,1082
-Flask_Migrate-4.1.0.dist-info/METADATA,sha256=Oc_YNcJGhss0camLTDR64sz2RuLXAppze2rvHDzS8_0,3296
-Flask_Migrate-4.1.0.dist-info/RECORD,,
-Flask_Migrate-4.1.0.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
-Flask_Migrate-4.1.0.dist-info/WHEEL,sha256=P9jw-gEje8ByB7_hXoICnHtVCrEwMQh-630tKvQWehc,91
-Flask_Migrate-4.1.0.dist-info/top_level.txt,sha256=jLoPgiMG6oR4ugNteXn3IHskVVIyIXVStZOVq-AWLdU,14
+flask_migrate-4.1.0.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
+flask_migrate-4.1.0.dist-info/METADATA,sha256=1bA492meS2k4iqK2vBX3qIbYKl4uAi7lsOztLMAStHw,3311
+flask_migrate-4.1.0.dist-info/RECORD,,
+flask_migrate-4.1.0.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
+flask_migrate-4.1.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
+flask_migrate-4.1.0.dist-info/licenses/LICENSE,sha256=kfkXGlJQvKy3Y__6tAJ8ynIp1HQfeROXhL8jZU1d-DI,1082
+flask_migrate-4.1.0.dist-info/top_level.txt,sha256=jLoPgiMG6oR4ugNteXn3IHskVVIyIXVStZOVq-AWLdU,14
flask_migrate/__init__.py,sha256=JMySGA55Y8Gxy3HviWu7qq5rPUNQBWc2NID2OicpDyw,10082
flask_migrate/cli.py,sha256=IxrxBSC82S5sPfWac8Qg83_FVsRvqTYtCG7HRyMW8RU,11097
flask_migrate/templates/aioflask-multidb/README,sha256=Ek4cJqTaxneVjtkue--BXMlfpfp3MmJRjqoZvnSizww,43
diff --git a/libs/Flask_Migrate-4.1.0.dist-info/WHEEL b/libs/Flask_Migrate-4.1.0.dist-info/WHEEL
index 9b78c44519..0885d05555 100644
--- a/libs/Flask_Migrate-4.1.0.dist-info/WHEEL
+++ b/libs/Flask_Migrate-4.1.0.dist-info/WHEEL
@@ -1,5 +1,5 @@
Wheel-Version: 1.0
-Generator: setuptools (75.3.0)
+Generator: setuptools (80.10.2)
Root-Is-Purelib: true
Tag: py3-none-any
diff --git a/libs/Flask_Migrate-4.1.0.dist-info/LICENSE b/libs/Flask_Migrate-4.1.0.dist-info/licenses/LICENSE
similarity index 100%
rename from libs/Flask_Migrate-4.1.0.dist-info/LICENSE
rename to libs/Flask_Migrate-4.1.0.dist-info/licenses/LICENSE
diff --git a/libs/Flask_SocketIO-5.5.1.dist-info/METADATA b/libs/Flask_SocketIO-5.5.1.dist-info/METADATA
deleted file mode 100644
index 8676b6140c..0000000000
--- a/libs/Flask_SocketIO-5.5.1.dist-info/METADATA
+++ /dev/null
@@ -1,76 +0,0 @@
-Metadata-Version: 2.1
-Name: Flask-SocketIO
-Version: 5.5.1
-Summary: Socket.IO integration for Flask applications
-Author-email: Miguel Grinberg
-Project-URL: Homepage, https://github.com/miguelgrinberg/flask-socketio
-Project-URL: Bug Tracker, https://github.com/miguelgrinberg/flask-socketio/issues
-Classifier: Environment :: Web Environment
-Classifier: Intended Audience :: Developers
-Classifier: Programming Language :: Python :: 3
-Classifier: License :: OSI Approved :: MIT License
-Classifier: Operating System :: OS Independent
-Requires-Python: >=3.6
-Description-Content-Type: text/markdown
-License-File: LICENSE
-Requires-Dist: Flask >=0.9
-Requires-Dist: python-socketio >=5.12.0
-Provides-Extra: docs
-Requires-Dist: sphinx ; extra == 'docs'
-
-Flask-SocketIO
-==============
-
-[](https://github.com/miguelgrinberg/Flask-SocketIO/actions) [](https://codecov.io/gh/miguelgrinberg/flask-socketio)
-
-Socket.IO integration for Flask applications.
-
-Sponsors
---------
-
-The following organizations are funding this project:
-
- [Socket.IO](https://socket.io) | [Add your company here!](https://github.com/sponsors/miguelgrinberg)|
--|-
-
-Many individual sponsors also support this project through small ongoing contributions. Why not [join them](https://github.com/sponsors/miguelgrinberg)?
-
-Installation
-------------
-
-You can install this package as usual with pip:
-
- pip install flask-socketio
-
-Example
--------
-
-```py
-from flask import Flask, render_template
-from flask_socketio import SocketIO, emit
-
-app = Flask(__name__)
-app.config['SECRET_KEY'] = 'secret!'
-socketio = SocketIO(app)
-
-@app.route('/')
-def index():
- return render_template('index.html')
-
-@socketio.event
-def my_event(message):
- emit('my response', {'data': 'got it!'})
-
-if __name__ == '__main__':
- socketio.run(app)
-```
-
-Resources
----------
-
-- [Tutorial](http://blog.miguelgrinberg.com/post/easy-websockets-with-flask-and-gevent)
-- [Documentation](http://flask-socketio.readthedocs.io/en/latest/)
-- [PyPI](https://pypi.python.org/pypi/Flask-SocketIO)
-- [Change Log](https://github.com/miguelgrinberg/Flask-SocketIO/blob/main/CHANGES.md)
-- Questions? See the [questions](https://stackoverflow.com/questions/tagged/flask-socketio) others have asked on Stack Overflow, or [ask](https://stackoverflow.com/questions/ask?tags=python+flask-socketio+python-socketio) your own question.
-
diff --git a/libs/Flask_SocketIO-5.5.1.dist-info/RECORD b/libs/Flask_SocketIO-5.5.1.dist-info/RECORD
deleted file mode 100644
index 2b50d3dda3..0000000000
--- a/libs/Flask_SocketIO-5.5.1.dist-info/RECORD
+++ /dev/null
@@ -1,10 +0,0 @@
-Flask_SocketIO-5.5.1.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
-Flask_SocketIO-5.5.1.dist-info/LICENSE,sha256=aNCWbkgKjS_T1cJtACyZbvCM36KxWnfQ0LWTuavuYKQ,1082
-Flask_SocketIO-5.5.1.dist-info/METADATA,sha256=6NSCK70GFvnCHNKwcr6lmffkRAKLd9dOnGq6TbAJlfs,2638
-Flask_SocketIO-5.5.1.dist-info/RECORD,,
-Flask_SocketIO-5.5.1.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
-Flask_SocketIO-5.5.1.dist-info/WHEEL,sha256=P9jw-gEje8ByB7_hXoICnHtVCrEwMQh-630tKvQWehc,91
-Flask_SocketIO-5.5.1.dist-info/top_level.txt,sha256=C1ugzQBJ3HHUJsWGzyt70XRVOX-y4CUAR8MWKjwJOQ8,15
-flask_socketio/__init__.py,sha256=5hN0LE0hfGMUDcX4FheZrtXERJ1IBEPagv0pgeqdtlU,54904
-flask_socketio/namespace.py,sha256=UkVryJvFYgnCMKWSF35GVfGdyh2cXRDyRbfmEPPchVA,2329
-flask_socketio/test_client.py,sha256=rClk02TSRqgidH8IyeohspKVKdpRx7gcZBjg1YUtZpA,11026
diff --git a/libs/Flask_SocketIO-5.5.1.dist-info/WHEEL b/libs/Flask_SocketIO-5.5.1.dist-info/WHEEL
deleted file mode 100644
index 9b78c44519..0000000000
--- a/libs/Flask_SocketIO-5.5.1.dist-info/WHEEL
+++ /dev/null
@@ -1,5 +0,0 @@
-Wheel-Version: 1.0
-Generator: setuptools (75.3.0)
-Root-Is-Purelib: true
-Tag: py3-none-any
-
diff --git a/libs/Js2Py-0.74.dist-info/METADATA b/libs/Js2Py-0.74.dist-info/METADATA
index 4063e4ba92..a35be0b740 100644
--- a/libs/Js2Py-0.74.dist-info/METADATA
+++ b/libs/Js2Py-0.74.dist-info/METADATA
@@ -1,4 +1,4 @@
-Metadata-Version: 2.1
+Metadata-Version: 2.4
Name: Js2Py
Version: 0.74
Summary: JavaScript to Python Translator & JavaScript interpreter written in 100% pure Python.
@@ -7,9 +7,17 @@ Author: Piotr Dabkowski
Author-email: piodrus@gmail.com
License: MIT
License-File: LICENSE.md
-Requires-Dist: tzlocal >=1.2
-Requires-Dist: six >=1.10
-Requires-Dist: pyjsparser >=2.5.1
+Requires-Dist: tzlocal>=1.2
+Requires-Dist: six>=1.10
+Requires-Dist: pyjsparser>=2.5.1
+Dynamic: author
+Dynamic: author-email
+Dynamic: description
+Dynamic: home-page
+Dynamic: license
+Dynamic: license-file
+Dynamic: requires-dist
+Dynamic: summary
Translates JavaScript to Python code. Js2Py is able to translate and execute virtually any JavaScript code.
diff --git a/libs/Js2Py-0.74.dist-info/RECORD b/libs/Js2Py-0.74.dist-info/RECORD
index d14c3b06c8..cf583183c6 100644
--- a/libs/Js2Py-0.74.dist-info/RECORD
+++ b/libs/Js2Py-0.74.dist-info/RECORD
@@ -1,10 +1,10 @@
-Js2Py-0.74.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
-Js2Py-0.74.dist-info/LICENSE.md,sha256=5HcnGlDEDJrDrVbQLnDH19l_ZscybPUk0TsS-IQsxOk,1088
-Js2Py-0.74.dist-info/METADATA,sha256=shZhquhN4nVuaurudCERGEnLk38BjT7grtZLNg4MABc,862
-Js2Py-0.74.dist-info/RECORD,,
-Js2Py-0.74.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
-Js2Py-0.74.dist-info/WHEEL,sha256=Xo9-1PvkuimrydujYJAjF7pCkriuXBpUPEjma1nZyJ0,92
-Js2Py-0.74.dist-info/top_level.txt,sha256=Me1vDvBnqRgA6Jf96euhHjsa-dYkaXpr3Sm0RGPoGn8,6
+js2py-0.74.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
+js2py-0.74.dist-info/METADATA,sha256=EsxDQrBB3A2LSeMzBE55q-4t-GTdP7AD1iLqykYiv2Q,1016
+js2py-0.74.dist-info/RECORD,,
+js2py-0.74.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
+js2py-0.74.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
+js2py-0.74.dist-info/licenses/LICENSE.md,sha256=5HcnGlDEDJrDrVbQLnDH19l_ZscybPUk0TsS-IQsxOk,1088
+js2py-0.74.dist-info/top_level.txt,sha256=Me1vDvBnqRgA6Jf96euhHjsa-dYkaXpr3Sm0RGPoGn8,6
js2py/__init__.py,sha256=VlWswk9Sf3qBwxaJ1E9STDxUdTYh3PLKp6Kn1ws0STE,2886
js2py/base.py,sha256=_h7HbsB30cybzGAU7XIX5tawMA4C7IHFsRi_ESRqzZc,115810
js2py/constructors/__init__.py,sha256=isKbPQhm2gf7O6f4aW0F9J0yBlds8jYDq5xA4u08xrE,30
diff --git a/libs/Js2Py-0.74.dist-info/WHEEL b/libs/Js2Py-0.74.dist-info/WHEEL
index ba48cbcf92..0885d05555 100644
--- a/libs/Js2Py-0.74.dist-info/WHEEL
+++ b/libs/Js2Py-0.74.dist-info/WHEEL
@@ -1,5 +1,5 @@
Wheel-Version: 1.0
-Generator: bdist_wheel (0.41.3)
+Generator: setuptools (80.10.2)
Root-Is-Purelib: true
Tag: py3-none-any
diff --git a/libs/Js2Py-0.74.dist-info/LICENSE.md b/libs/Js2Py-0.74.dist-info/licenses/LICENSE.md
similarity index 100%
rename from libs/Js2Py-0.74.dist-info/LICENSE.md
rename to libs/Js2Py-0.74.dist-info/licenses/LICENSE.md
diff --git a/libs/Mako-1.3.8.dist-info/LICENSE b/libs/Mako-1.3.8.dist-info/LICENSE
deleted file mode 100644
index 7cf3d43378..0000000000
--- a/libs/Mako-1.3.8.dist-info/LICENSE
+++ /dev/null
@@ -1,19 +0,0 @@
-Copyright 2006-2024 the Mako authors and contributors .
-
-Permission is hereby granted, free of charge, to any person obtaining a copy of
-this software and associated documentation files (the "Software"), to deal in
-the Software without restriction, including without limitation the rights to
-use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
-of the Software, and to permit persons to whom the Software is furnished to do
-so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
diff --git a/libs/Mako-1.3.8.dist-info/METADATA b/libs/Mako-1.3.8.dist-info/METADATA
deleted file mode 100644
index bf0b1f45b9..0000000000
--- a/libs/Mako-1.3.8.dist-info/METADATA
+++ /dev/null
@@ -1,87 +0,0 @@
-Metadata-Version: 2.1
-Name: Mako
-Version: 1.3.8
-Summary: A super-fast templating language that borrows the best ideas from the existing templating languages.
-Home-page: https://www.makotemplates.org/
-Author: Mike Bayer
-Author-email: mike@zzzcomputing.com
-License: MIT
-Project-URL: Documentation, https://docs.makotemplates.org
-Project-URL: Issue Tracker, https://github.com/sqlalchemy/mako
-Classifier: Development Status :: 5 - Production/Stable
-Classifier: License :: OSI Approved :: MIT License
-Classifier: Environment :: Web Environment
-Classifier: Intended Audience :: Developers
-Classifier: Programming Language :: Python
-Classifier: Programming Language :: Python :: 3
-Classifier: Programming Language :: Python :: 3.8
-Classifier: Programming Language :: Python :: 3.9
-Classifier: Programming Language :: Python :: 3.10
-Classifier: Programming Language :: Python :: 3.11
-Classifier: Programming Language :: Python :: 3.12
-Classifier: Programming Language :: Python :: Implementation :: CPython
-Classifier: Programming Language :: Python :: Implementation :: PyPy
-Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
-Requires-Python: >=3.8
-Description-Content-Type: text/x-rst
-License-File: LICENSE
-Requires-Dist: MarkupSafe>=0.9.2
-Provides-Extra: babel
-Requires-Dist: Babel; extra == "babel"
-Provides-Extra: lingua
-Requires-Dist: lingua; extra == "lingua"
-Provides-Extra: testing
-Requires-Dist: pytest; extra == "testing"
-
-=========================
-Mako Templates for Python
-=========================
-
-Mako is a template library written in Python. It provides a familiar, non-XML
-syntax which compiles into Python modules for maximum performance. Mako's
-syntax and API borrows from the best ideas of many others, including Django
-templates, Cheetah, Myghty, and Genshi. Conceptually, Mako is an embedded
-Python (i.e. Python Server Page) language, which refines the familiar ideas
-of componentized layout and inheritance to produce one of the most
-straightforward and flexible models available, while also maintaining close
-ties to Python calling and scoping semantics.
-
-Nutshell
-========
-
-::
-
- <%inherit file="base.html"/>
- <%
- rows = [[v for v in range(0,10)] for row in range(0,10)]
- %>
-
- % for row in rows:
- ${makerow(row)}
- % endfor
-
-
- <%def name="makerow(row)">
-
- % for name in row:
- ${name} \
- % endfor
-
- %def>
-
-Philosophy
-===========
-
-Python is a great scripting language. Don't reinvent the wheel...your templates can handle it !
-
-Documentation
-==============
-
-See documentation for Mako at https://docs.makotemplates.org/en/latest/
-
-License
-========
-
-Mako is licensed under an MIT-style license (see LICENSE).
-Other incorporated projects may be licensed under different licenses.
-All licenses allow for non-commercial and commercial use.
diff --git a/libs/Mako-1.3.8.dist-info/RECORD b/libs/Mako-1.3.8.dist-info/RECORD
deleted file mode 100644
index 7e79ae7aa2..0000000000
--- a/libs/Mako-1.3.8.dist-info/RECORD
+++ /dev/null
@@ -1,42 +0,0 @@
-../../bin/mako-render,sha256=NK39DgCmw8pz5T7ALDcW2MB6hFGNVOpWXAHq3-GKyss,236
-Mako-1.3.8.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
-Mako-1.3.8.dist-info/LICENSE,sha256=FWJ7NrONBynN1obfmr9gZQPZnWJLL17FyyVKddWvqJE,1098
-Mako-1.3.8.dist-info/METADATA,sha256=YtMX8Z6wVX7TvuBzOsUAOAq_jdceHFW4rR6hwvMNZgE,2896
-Mako-1.3.8.dist-info/RECORD,,
-Mako-1.3.8.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
-Mako-1.3.8.dist-info/WHEEL,sha256=P9jw-gEje8ByB7_hXoICnHtVCrEwMQh-630tKvQWehc,91
-Mako-1.3.8.dist-info/entry_points.txt,sha256=LsKkUsOsJQYbJ2M72hZCm968wi5K8Ywb5uFxCuN8Obk,512
-Mako-1.3.8.dist-info/top_level.txt,sha256=LItdH8cDPetpUu8rUyBG3DObS6h9Gcpr9j_WLj2S-R0,5
-mako/__init__.py,sha256=sMLX8sANJQjjeIsZjbrwotWPXHEpRcKxELPgkx2Cyw8,242
-mako/_ast_util.py,sha256=CenxCrdES1irHDhOQU6Ldta4rdsytfYaMkN6s0TlveM,20247
-mako/ast.py,sha256=pY7MH-5cLnUuVz5YAwoGhWgWfgoVvLQkRDtc_s9qqw0,6642
-mako/cache.py,sha256=5DBBorj1NqiWDqNhN3ZJ8tMCm-h6Mew541276kdsxAU,7680
-mako/cmd.py,sha256=vP5M5g9yc5sjAT5owVTQu056YwyS-YkpulFSDb0IMGw,2813
-mako/codegen.py,sha256=XRhzcuGEleDUXTfmOjw4alb6TkczbmEfBCLqID8x4bA,47736
-mako/compat.py,sha256=wjVMf7uMg0TlC_aI5hdwWizza99nqJuGNdrnTNrZbt0,1820
-mako/exceptions.py,sha256=pfdd5-1lCZ--I2YqQ_oHODZLmo62bn_lO5Kz_1__72w,12530
-mako/ext/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
-mako/ext/autohandler.py,sha256=Tyz1CLRlG_C0EnKgjuHzqS4BBnpeA49O05x8rriNtTY,1885
-mako/ext/babelplugin.py,sha256=v10o5XQdgXwbr1bo0aL8VV3THm_84C_eeq6BmAGd3uA,2091
-mako/ext/beaker_cache.py,sha256=aAs9ELzO2WaiO5OppV4KDT6f7yNyn1BF1XHDQZwf0-E,2578
-mako/ext/extract.py,sha256=c3YuIN3Z5ZgS-xzX_gjKrEVQaptK3liXkm5j-Vq8yEM,4659
-mako/ext/linguaplugin.py,sha256=pzHlHC3-KlFeVAR4r8S1--_dfE5DcYmjLXtr0genBYU,1935
-mako/ext/preprocessors.py,sha256=zKQy42Ce6dOmU0Yk_rUVDAAn38-RUUfQolVKTJjLotA,576
-mako/ext/pygmentplugin.py,sha256=qBdsAhKktlQX7d5Yv1sAXufUNOZqcnJmKuC7V4D_srM,4753
-mako/ext/turbogears.py,sha256=0emY1WiMnuY8Pf6ARv5JBArKtouUdmuTljI-w6rE3J4,2141
-mako/filters.py,sha256=F7aDIKTUxnT-Og4rgboQtnML7Q87DJTHQyhi_dY_Ih4,4658
-mako/lexer.py,sha256=Xi6Lk8CnASf3UYAaPoYrfjuPkrYauNjvYvULCUkKYaY,16321
-mako/lookup.py,sha256=rkMvT5T7EOS5KRvPtgYii-sjh1nWWyKok_mEk-cEzrM,12428
-mako/parsetree.py,sha256=BHdZI9vyxKB27Q4hzym5TdZ_982_3k31_HMsGLz3Tlg,19021
-mako/pygen.py,sha256=d4f_ugRACCXuV9hJgEk6Ncoj38EaRHA3RTxkr_tK7UQ,10416
-mako/pyparser.py,sha256=eY_a94QDXaK3vIA2jZYT9so7oXKKJLT0SO_Yrl3IOb8,7478
-mako/runtime.py,sha256=ZsUEN22nX3d3dECQujF69mBKDQS6yVv2nvz_0eTvFGg,27804
-mako/template.py,sha256=4xQzwruZd5XzPw7iONZMZJj4SdFsctYYg4PfBYs2PLk,23857
-mako/testing/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
-mako/testing/_config.py,sha256=k-qpnsnbXUoN-ykMN5BRpg84i1x0p6UsAddKQnrIytU,3566
-mako/testing/assertions.py,sha256=pfbGl84QlW7QWGg3_lo3wP8XnBAVo9AjzNp2ajmn7FA,5161
-mako/testing/config.py,sha256=wmYVZfzGvOK3mJUZpzmgO8-iIgvaCH41Woi4yDpxq6E,323
-mako/testing/exclusions.py,sha256=_t6ADKdatk3f18tOfHV_ZY6u_ZwQsKphZ2MXJVSAOcI,1553
-mako/testing/fixtures.py,sha256=nEp7wTusf7E0n3Q-BHJW2s_t1vx0KB9poadQ1BmIJzE,3044
-mako/testing/helpers.py,sha256=z4HAactwlht4ut1cbvxKt1QLb3yLPk1U7cnh5BwVUlc,1623
-mako/util.py,sha256=dIFuchHfiNtRJJ99kEIRdHBkCZ3UmEvNO6l2ZQSCdVU,10638
diff --git a/libs/Mako-1.3.8.dist-info/WHEEL b/libs/Mako-1.3.8.dist-info/WHEEL
deleted file mode 100644
index 9b78c44519..0000000000
--- a/libs/Mako-1.3.8.dist-info/WHEEL
+++ /dev/null
@@ -1,5 +0,0 @@
-Wheel-Version: 1.0
-Generator: setuptools (75.3.0)
-Root-Is-Purelib: true
-Tag: py3-none-any
-
diff --git a/libs/Markdown-3.7.dist-info/METADATA b/libs/Markdown-3.7.dist-info/METADATA
deleted file mode 100644
index 233bc55baa..0000000000
--- a/libs/Markdown-3.7.dist-info/METADATA
+++ /dev/null
@@ -1,146 +0,0 @@
-Metadata-Version: 2.1
-Name: Markdown
-Version: 3.7
-Summary: Python implementation of John Gruber's Markdown.
-Author: Manfred Stienstra, Yuri Takhteyev
-Author-email: Waylan limberg
-Maintainer: Isaac Muse
-Maintainer-email: Waylan Limberg
-License: BSD 3-Clause License
-
- Copyright 2007, 2008 The Python Markdown Project (v. 1.7 and later)
- Copyright 2004, 2005, 2006 Yuri Takhteyev (v. 0.2-1.6b)
- Copyright 2004 Manfred Stienstra (the original version)
-
- Redistribution and use in source and binary forms, with or without
- modification, are permitted provided that the following conditions are met:
-
- 1. Redistributions of source code must retain the above copyright notice, this
- list of conditions and the following disclaimer.
-
- 2. Redistributions in binary form must reproduce the above copyright notice,
- this list of conditions and the following disclaimer in the documentation
- and/or other materials provided with the distribution.
-
- 3. Neither the name of the copyright holder nor the names of its
- contributors may be used to endorse or promote products derived from
- this software without specific prior written permission.
-
- THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
- AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
- IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
- DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
- FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
- DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
- SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
- CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
- OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
- OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
-
-Project-URL: Homepage, https://Python-Markdown.github.io/
-Project-URL: Documentation, https://Python-Markdown.github.io/
-Project-URL: Repository, https://github.com/Python-Markdown/markdown
-Project-URL: Issue Tracker, https://github.com/Python-Markdown/markdown/issues
-Project-URL: Changelog, https://python-markdown.github.io/changelog/
-Keywords: markdown,markdown-parser,python-markdown,markdown-to-html
-Classifier: Development Status :: 5 - Production/Stable
-Classifier: License :: OSI Approved :: BSD License
-Classifier: Operating System :: OS Independent
-Classifier: Programming Language :: Python
-Classifier: Programming Language :: Python :: 3
-Classifier: Programming Language :: Python :: 3.8
-Classifier: Programming Language :: Python :: 3.9
-Classifier: Programming Language :: Python :: 3.10
-Classifier: Programming Language :: Python :: 3.11
-Classifier: Programming Language :: Python :: 3.12
-Classifier: Programming Language :: Python :: 3 :: Only
-Classifier: Programming Language :: Python :: Implementation :: CPython
-Classifier: Programming Language :: Python :: Implementation :: PyPy
-Classifier: Topic :: Communications :: Email :: Filters
-Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content :: CGI Tools/Libraries
-Classifier: Topic :: Internet :: WWW/HTTP :: Site Management
-Classifier: Topic :: Software Development :: Documentation
-Classifier: Topic :: Software Development :: Libraries :: Python Modules
-Classifier: Topic :: Text Processing :: Filters
-Classifier: Topic :: Text Processing :: Markup :: HTML
-Classifier: Topic :: Text Processing :: Markup :: Markdown
-Requires-Python: >=3.8
-Description-Content-Type: text/markdown
-License-File: LICENSE.md
-Requires-Dist: importlib-metadata >=4.4 ; python_version < "3.10"
-Provides-Extra: docs
-Requires-Dist: mkdocs >=1.5 ; extra == 'docs'
-Requires-Dist: mkdocs-nature >=0.6 ; extra == 'docs'
-Requires-Dist: mdx-gh-links >=0.2 ; extra == 'docs'
-Requires-Dist: mkdocstrings[python] ; extra == 'docs'
-Requires-Dist: mkdocs-gen-files ; extra == 'docs'
-Requires-Dist: mkdocs-section-index ; extra == 'docs'
-Requires-Dist: mkdocs-literate-nav ; extra == 'docs'
-Provides-Extra: testing
-Requires-Dist: coverage ; extra == 'testing'
-Requires-Dist: pyyaml ; extra == 'testing'
-
-[Python-Markdown][]
-===================
-
-[![Build Status][build-button]][build]
-[![Coverage Status][codecov-button]][codecov]
-[![Latest Version][mdversion-button]][md-pypi]
-[![Python Versions][pyversion-button]][md-pypi]
-[![BSD License][bsdlicense-button]][bsdlicense]
-[![Code of Conduct][codeofconduct-button]][Code of Conduct]
-
-[build-button]: https://github.com/Python-Markdown/markdown/workflows/CI/badge.svg?event=push
-[build]: https://github.com/Python-Markdown/markdown/actions?query=workflow%3ACI+event%3Apush
-[codecov-button]: https://codecov.io/gh/Python-Markdown/markdown/branch/master/graph/badge.svg
-[codecov]: https://codecov.io/gh/Python-Markdown/markdown
-[mdversion-button]: https://img.shields.io/pypi/v/Markdown.svg
-[md-pypi]: https://pypi.org/project/Markdown/
-[pyversion-button]: https://img.shields.io/pypi/pyversions/Markdown.svg
-[bsdlicense-button]: https://img.shields.io/badge/license-BSD-yellow.svg
-[bsdlicense]: https://opensource.org/licenses/BSD-3-Clause
-[codeofconduct-button]: https://img.shields.io/badge/code%20of%20conduct-contributor%20covenant-green.svg?style=flat-square
-[Code of Conduct]: https://github.com/Python-Markdown/markdown/blob/master/CODE_OF_CONDUCT.md
-
-This is a Python implementation of John Gruber's [Markdown][].
-It is almost completely compliant with the reference implementation,
-though there are a few known issues. See [Features][] for information
-on what exactly is supported and what is not. Additional features are
-supported by the [Available Extensions][].
-
-[Python-Markdown]: https://Python-Markdown.github.io/
-[Markdown]: https://daringfireball.net/projects/markdown/
-[Features]: https://Python-Markdown.github.io#Features
-[Available Extensions]: https://Python-Markdown.github.io/extensions
-
-Documentation
--------------
-
-```bash
-pip install markdown
-```
-```python
-import markdown
-html = markdown.markdown(your_text_string)
-```
-
-For more advanced [installation] and [usage] documentation, see the `docs/` directory
-of the distribution or the project website at .
-
-[installation]: https://python-markdown.github.io/install/
-[usage]: https://python-markdown.github.io/reference/
-
-See the change log at .
-
-Support
--------
-
-You may report bugs, ask for help, and discuss various other issues on the [bug tracker][].
-
-[bug tracker]: https://github.com/Python-Markdown/markdown/issues
-
-Code of Conduct
----------------
-
-Everyone interacting in the Python-Markdown project's code bases, issue trackers,
-and mailing lists is expected to follow the [Code of Conduct].
diff --git a/libs/Markdown-3.7.dist-info/RECORD b/libs/Markdown-3.7.dist-info/RECORD
deleted file mode 100644
index 34d25a0c7e..0000000000
--- a/libs/Markdown-3.7.dist-info/RECORD
+++ /dev/null
@@ -1,42 +0,0 @@
-../../bin/markdown_py,sha256=a0a3HrUHepb4z4hcrRdCfAEQ8SiB-QoWxf9g1e-KLv8,237
-Markdown-3.7.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
-Markdown-3.7.dist-info/LICENSE.md,sha256=e6TrbRCzKy0R3OE4ITQDUc27swuozMZ4Qdsv_Ybnmso,1650
-Markdown-3.7.dist-info/METADATA,sha256=nY8sewcY6R1akyROqkyO-Jk_eUDY8am_C4MkRP79sWA,7040
-Markdown-3.7.dist-info/RECORD,,
-Markdown-3.7.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
-Markdown-3.7.dist-info/WHEEL,sha256=OVMc5UfuAQiSplgO0_WdW7vXVGAt9Hdd6qtN4HotdyA,91
-Markdown-3.7.dist-info/entry_points.txt,sha256=lMEyiiA_ZZyfPCBlDviBl-SiU0cfoeuEKpwxw361sKQ,1102
-Markdown-3.7.dist-info/top_level.txt,sha256=IAxs8x618RXoH1uCqeLLxXsDefJvE_mIibr_M4sOlyk,9
-markdown/__init__.py,sha256=dfzwwdpG9L8QLEPBpLFPIHx_BN056aZXp9xZifTxYIU,1777
-markdown/__main__.py,sha256=innFBxRqwPBNxG1zhKktJji4bnRKtVyYYd30ID13Tcw,5859
-markdown/__meta__.py,sha256=RhwfJ30zyGvJaJXLHwQdNH5jw69-5fVKu2p-CVaJz0U,1712
-markdown/blockparser.py,sha256=j4CQImVpiq7g9pz8wCxvzT61X_T2iSAjXupHJk8P3eA,5728
-markdown/blockprocessors.py,sha256=koY5rq8DixzBCHcquvZJp6x2JYyBGjrwxMWNZhd6D2U,27013
-markdown/core.py,sha256=DyyzDsmd-KcuEp8ZWUKJAeUCt7B7G3J3NeqZqp3LphI,21335
-markdown/extensions/__init__.py,sha256=9z1khsdKCVrmrJ_2GfxtPAdjD3FyMe5vhC7wmM4O9m0,4822
-markdown/extensions/abbr.py,sha256=Gqt9TUtLWez2cbsy3SQk5152RZekops2fUJj01bfkfw,6903
-markdown/extensions/admonition.py,sha256=Hqcn3I8JG0i-OPWdoqI189TmlQRgH6bs5PmpCANyLlg,6547
-markdown/extensions/attr_list.py,sha256=t3PrgAr5Ebldnq3nJNbteBt79bN0ccXS5RemmQfUZ9g,7820
-markdown/extensions/codehilite.py,sha256=ChlmpM6S--j-UK7t82859UpYjm8EftdiLqmgDnknyes,13503
-markdown/extensions/def_list.py,sha256=J3NVa6CllfZPsboJCEycPyRhtjBHnOn8ET6omEvVlDo,4029
-markdown/extensions/extra.py,sha256=1vleT284kued4HQBtF83IjSumJVo0q3ng6MjTkVNfNQ,2163
-markdown/extensions/fenced_code.py,sha256=-fYSmRZ9DTYQ8HO9b_78i47kVyVu6mcVJlqVTMdzvo4,8300
-markdown/extensions/footnotes.py,sha256=bRFlmIBOKDI5efG1jZfDkMoV2osfqWip1rN1j2P-mMg,16710
-markdown/extensions/legacy_attrs.py,sha256=oWcyNrfP0F6zsBoBOaD5NiwrJyy4kCpgQLl12HA7JGU,2788
-markdown/extensions/legacy_em.py,sha256=-Z_w4PEGSS-Xg-2-BtGAnXwwy5g5GDgv2tngASnPgxg,1693
-markdown/extensions/md_in_html.py,sha256=y4HEWEnkvfih22fojcaJeAmjx1AtF8N-a_jb6IDFfts,16546
-markdown/extensions/meta.py,sha256=v_4Uq7nbcQ76V1YAvqVPiNLbRLIQHJsnfsk-tN70RmY,2600
-markdown/extensions/nl2br.py,sha256=9KKcrPs62c3ENNnmOJZs0rrXXqUtTCfd43j1_OPpmgU,1090
-markdown/extensions/sane_lists.py,sha256=ogAKcm7gEpcXV7fSTf8JZH5YdKAssPCEOUzdGM3C9Tw,2150
-markdown/extensions/smarty.py,sha256=yqT0OiE2AqYrqqZtcUFFmp2eJsQHomiKzgyG2JFb9rI,11048
-markdown/extensions/tables.py,sha256=oTDvGD1qp9xjVWPGYNgDBWe9NqsX5gS6UU5wUsQ1bC8,8741
-markdown/extensions/toc.py,sha256=PGg-EqbBubm3n0b633r8Xa9kc6JIdbo20HGAOZ6GEl8,18322
-markdown/extensions/wikilinks.py,sha256=j7D2sozica6sqXOUa_GuAXqIzxp-7Hi60bfXymiuma8,3285
-markdown/htmlparser.py,sha256=dEr6IE7i9b6Tc1gdCLZGeWw6g6-E-jK1Z4KPj8yGk8Q,14332
-markdown/inlinepatterns.py,sha256=7_HF5nTOyQag_CyBgU4wwmuI6aMjtadvGadyS9IP21w,38256
-markdown/postprocessors.py,sha256=eYi6eW0mGudmWpmsW45hduLwX66Zr8Bf44WyU9vKp-I,4807
-markdown/preprocessors.py,sha256=pq5NnHKkOSVQeIo-ajC-Yt44kvyMV97D04FBOQXctJM,3224
-markdown/serializers.py,sha256=YtAFYQoOdp_TAmYGow6nBo0eB6I-Sl4PTLdLDfQJHwQ,7174
-markdown/test_tools.py,sha256=MtN4cf3ZPDtb83wXLTol-3q3aIGRIkJ2zWr6fd-RgVE,8662
-markdown/treeprocessors.py,sha256=o4dnoZZsIeVV8qR45Njr8XgwKleWYDS5pv8dKQhJvv8,17651
-markdown/util.py,sha256=vJ1E0xjMzDAlTqLUSJWgdEvxdQfLXDEYUssOQMw9kPQ,13929
diff --git a/libs/Markdown-3.7.dist-info/WHEEL b/libs/Markdown-3.7.dist-info/WHEEL
deleted file mode 100644
index da25d7b423..0000000000
--- a/libs/Markdown-3.7.dist-info/WHEEL
+++ /dev/null
@@ -1,5 +0,0 @@
-Wheel-Version: 1.0
-Generator: setuptools (75.2.0)
-Root-Is-Purelib: true
-Tag: py3-none-any
-
diff --git a/libs/MarkupSafe-2.1.5.dist-info/METADATA b/libs/MarkupSafe-2.1.5.dist-info/METADATA
deleted file mode 100644
index dfe37d52df..0000000000
--- a/libs/MarkupSafe-2.1.5.dist-info/METADATA
+++ /dev/null
@@ -1,93 +0,0 @@
-Metadata-Version: 2.1
-Name: MarkupSafe
-Version: 2.1.5
-Summary: Safely add untrusted strings to HTML/XML markup.
-Home-page: https://palletsprojects.com/p/markupsafe/
-Maintainer: Pallets
-Maintainer-email: contact@palletsprojects.com
-License: BSD-3-Clause
-Project-URL: Donate, https://palletsprojects.com/donate
-Project-URL: Documentation, https://markupsafe.palletsprojects.com/
-Project-URL: Changes, https://markupsafe.palletsprojects.com/changes/
-Project-URL: Source Code, https://github.com/pallets/markupsafe/
-Project-URL: Issue Tracker, https://github.com/pallets/markupsafe/issues/
-Project-URL: Chat, https://discord.gg/pallets
-Classifier: Development Status :: 5 - Production/Stable
-Classifier: Environment :: Web Environment
-Classifier: Intended Audience :: Developers
-Classifier: License :: OSI Approved :: BSD License
-Classifier: Operating System :: OS Independent
-Classifier: Programming Language :: Python
-Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
-Classifier: Topic :: Text Processing :: Markup :: HTML
-Requires-Python: >=3.7
-Description-Content-Type: text/x-rst
-License-File: LICENSE.rst
-
-MarkupSafe
-==========
-
-MarkupSafe implements a text object that escapes characters so it is
-safe to use in HTML and XML. Characters that have special meanings are
-replaced so that they display as the actual characters. This mitigates
-injection attacks, meaning untrusted user input can safely be displayed
-on a page.
-
-
-Installing
-----------
-
-Install and update using `pip`_:
-
-.. code-block:: text
-
- pip install -U MarkupSafe
-
-.. _pip: https://pip.pypa.io/en/stable/getting-started/
-
-
-Examples
---------
-
-.. code-block:: pycon
-
- >>> from markupsafe import Markup, escape
-
- >>> # escape replaces special characters and wraps in Markup
- >>> escape("")
- Markup('<script>alert(document.cookie);</script>')
-
- >>> # wrap in Markup to mark text "safe" and prevent escaping
- >>> Markup("Hello ")
- Markup('hello ')
-
- >>> escape(Markup("Hello "))
- Markup('hello ')
-
- >>> # Markup is a str subclass
- >>> # methods and operators escape their arguments
- >>> template = Markup("Hello {name} ")
- >>> template.format(name='"World"')
- Markup('Hello "World" ')
-
-
-Donate
-------
-
-The Pallets organization develops and supports MarkupSafe and other
-popular packages. In order to grow the community of contributors and
-users, and allow the maintainers to devote more time to the projects,
-`please donate today`_.
-
-.. _please donate today: https://palletsprojects.com/donate
-
-
-Links
------
-
-- Documentation: https://markupsafe.palletsprojects.com/
-- Changes: https://markupsafe.palletsprojects.com/changes/
-- PyPI Releases: https://pypi.org/project/MarkupSafe/
-- Source Code: https://github.com/pallets/markupsafe/
-- Issue Tracker: https://github.com/pallets/markupsafe/issues/
-- Chat: https://discord.gg/pallets
diff --git a/libs/MarkupSafe-2.1.5.dist-info/RECORD b/libs/MarkupSafe-2.1.5.dist-info/RECORD
deleted file mode 100644
index 57cd62847c..0000000000
--- a/libs/MarkupSafe-2.1.5.dist-info/RECORD
+++ /dev/null
@@ -1,13 +0,0 @@
-MarkupSafe-2.1.5.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
-MarkupSafe-2.1.5.dist-info/LICENSE.rst,sha256=SJqOEQhQntmKN7uYPhHg9-HTHwvY-Zp5yESOf_N9B-o,1475
-MarkupSafe-2.1.5.dist-info/METADATA,sha256=2dRDPam6OZLfpX0wg1JN5P3u9arqACxVSfdGmsJU7o8,3003
-MarkupSafe-2.1.5.dist-info/RECORD,,
-MarkupSafe-2.1.5.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
-MarkupSafe-2.1.5.dist-info/WHEEL,sha256=EO1EUWjlSI9vqFKe-qOLBJFxSac53mP8l62vW3JFDec,109
-MarkupSafe-2.1.5.dist-info/top_level.txt,sha256=qy0Plje5IJuvsCBjejJyhDCjEAdcDLK_2agVcex8Z6U,11
-markupsafe/__init__.py,sha256=r7VOTjUq7EMQ4v3p4R1LoVOGJg6ysfYRncLr34laRBs,10958
-markupsafe/_native.py,sha256=GR86Qvo_GcgKmKreA1WmYN9ud17OFwkww8E-fiW-57s,1713
-markupsafe/_speedups.c,sha256=X2XvQVtIdcK4Usz70BvkzoOfjTCmQlDkkjYSn-swE0g,7083
-markupsafe/_speedups.cpython-38-darwin.so,sha256=1yfD14PZ-QrFSi3XHMHazowfHExBdp5WS7IC86gAuRc,18712
-markupsafe/_speedups.pyi,sha256=vfMCsOgbAXRNLUXkyuyonG8uEWKYU4PDqNuMaDELAYw,229
-markupsafe/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
diff --git a/libs/MarkupSafe-2.1.5.dist-info/WHEEL b/libs/MarkupSafe-2.1.5.dist-info/WHEEL
deleted file mode 100644
index 9fd57fe24f..0000000000
--- a/libs/MarkupSafe-2.1.5.dist-info/WHEEL
+++ /dev/null
@@ -1,5 +0,0 @@
-Wheel-Version: 1.0
-Generator: bdist_wheel (0.41.3)
-Root-Is-Purelib: false
-Tag: cp38-cp38-macosx_12_0_x86_64
-
diff --git a/libs/PlexAPI-4.16.1.dist-info/METADATA b/libs/PlexAPI-4.16.1.dist-info/METADATA
deleted file mode 100644
index 97bc91f4b1..0000000000
--- a/libs/PlexAPI-4.16.1.dist-info/METADATA
+++ /dev/null
@@ -1,282 +0,0 @@
-Metadata-Version: 2.1
-Name: PlexAPI
-Version: 4.16.1
-Summary: Python bindings for the Plex API.
-Author-email: Michael Shepanski
-License: BSD-3-Clause
-Project-URL: Homepage, https://github.com/pkkid/python-plexapi
-Project-URL: Documentation, https://python-plexapi.readthedocs.io
-Keywords: plex,api
-Classifier: Operating System :: OS Independent
-Classifier: Programming Language :: Python :: 3
-Classifier: License :: OSI Approved :: BSD License
-Requires-Python: >=3.9
-Description-Content-Type: text/x-rst
-License-File: LICENSE.txt
-License-File: AUTHORS.txt
-Requires-Dist: requests
-Provides-Extra: alert
-Requires-Dist: websocket-client (>=1.3.3) ; extra == 'alert'
-
-Python-PlexAPI
-==============
-.. image:: https://github.com/pkkid/python-plexapi/workflows/CI/badge.svg
- :target: https://github.com/pkkid/python-plexapi/actions?query=workflow%3ACI
-.. image:: https://readthedocs.org/projects/python-plexapi/badge/?version=latest
- :target: http://python-plexapi.readthedocs.io/en/latest/?badge=latest
-.. image:: https://codecov.io/gh/pkkid/python-plexapi/branch/master/graph/badge.svg?token=fOECznuMtw
- :target: https://codecov.io/gh/pkkid/python-plexapi
-.. image:: https://img.shields.io/github/tag/pkkid/python-plexapi.svg?label=github+release
- :target: https://github.com/pkkid/python-plexapi/releases
-.. image:: https://badge.fury.io/py/PlexAPI.svg
- :target: https://badge.fury.io/py/PlexAPI
-.. image:: https://img.shields.io/github/last-commit/pkkid/python-plexapi.svg
- :target: https://img.shields.io/github/last-commit/pkkid/python-plexapi.svg
-
-
-Overview
---------
-Unofficial Python bindings for the Plex API. Our goal is to match all capabilities of the official
-Plex Web Client. A few of the many features we currently support are:
-
-* Navigate local or remote shared libraries.
-* Perform library actions such as scan, analyze, empty trash.
-* Remote control and play media on connected clients, including `Controlling Sonos speakers`_
-* Listen in on all Plex Server notifications.
-
-
-Installation & Documentation
-----------------------------
-
-.. code-block:: python
-
- pip install plexapi
-
-*Install extra features:*
-
-.. code-block:: python
-
- pip install plexapi[alert] # Install with dependencies required for plexapi.alert
-
-Documentation_ can be found at Read the Docs.
-
-.. _Documentation: http://python-plexapi.readthedocs.io/en/latest/
-
-Join our Discord_ for support and discussion.
-
-.. _Discord: https://discord.gg/GtAnnZAkuw
-
-
-Getting a PlexServer Instance
------------------------------
-
-There are two types of authentication. If you are running on a separate network
-or using Plex Users you can log into MyPlex to get a PlexServer instance. An
-example of this is below. NOTE: Servername below is the name of the server (not
-the hostname and port). If logged into Plex Web you can see the server name in
-the top left above your available libraries.
-
-.. code-block:: python
-
- from plexapi.myplex import MyPlexAccount
- account = MyPlexAccount('', '')
- plex = account.resource('').connect() # returns a PlexServer instance
-
-If you want to avoid logging into MyPlex and you already know your auth token
-string, you can use the PlexServer object directly as above, by passing in
-the baseurl and auth token directly.
-
-.. code-block:: python
-
- from plexapi.server import PlexServer
- baseurl = 'http://plexserver:32400'
- token = '2ffLuB84dqLswk9skLos'
- plex = PlexServer(baseurl, token)
-
-
-Usage Examples
---------------
-
-.. code-block:: python
-
- # Example 1: List all unwatched movies.
- movies = plex.library.section('Movies')
- for video in movies.search(unwatched=True):
- print(video.title)
-
-
-.. code-block:: python
-
- # Example 2: Mark all Game of Thrones episodes as played.
- plex.library.section('TV Shows').get('Game of Thrones').markPlayed()
-
-
-.. code-block:: python
-
- # Example 3: List all clients connected to the Server.
- for client in plex.clients():
- print(client.title)
-
-
-.. code-block:: python
-
- # Example 4: Play the movie Cars on another client.
- # Note: Client must be on same network as server.
- cars = plex.library.section('Movies').get('Cars')
- client = plex.client("Michael's iPhone")
- client.playMedia(cars)
-
-
-.. code-block:: python
-
- # Example 5: List all content with the word 'Game' in the title.
- for video in plex.search('Game'):
- print(f'{video.title} ({video.TYPE})')
-
-
-.. code-block:: python
-
- # Example 6: List all movies directed by the same person as Elephants Dream.
- movies = plex.library.section('Movies')
- elephants_dream = movies.get('Elephants Dream')
- director = elephants_dream.directors[0]
- for movie in movies.search(None, director=director):
- print(movie.title)
-
-
-.. code-block:: python
-
- # Example 7: List files for the latest episode of The 100.
- last_episode = plex.library.section('TV Shows').get('The 100').episodes()[-1]
- for part in last_episode.iterParts():
- print(part.file)
-
-
-.. code-block:: python
-
- # Example 8: Get audio/video/all playlists
- for playlist in plex.playlists():
- print(playlist.title)
-
-
-.. code-block:: python
-
- # Example 9: Rate the 100 four stars.
- plex.library.section('TV Shows').get('The 100').rate(8.0)
-
-
-Controlling Sonos speakers
---------------------------
-
-To control Sonos speakers directly using Plex APIs, the following requirements must be met:
-
-1. Active Plex Pass subscription
-2. Sonos account linked to Plex account
-3. Plex remote access enabled
-
-Due to the design of Sonos music services, the API calls to control Sonos speakers route through https://sonos.plex.tv
-and back via the Plex server's remote access. Actual media playback is local unless networking restrictions prevent the
-Sonos speakers from connecting to the Plex server directly.
-
-.. code-block:: python
-
- from plexapi.myplex import MyPlexAccount
- from plexapi.server import PlexServer
-
- baseurl = 'http://plexserver:32400'
- token = '2ffLuB84dqLswk9skLos'
-
- account = MyPlexAccount(token)
- server = PlexServer(baseurl, token)
-
- # List available speakers/groups
- for speaker in account.sonos_speakers():
- print(speaker.title)
-
- # Obtain PlexSonosPlayer instance
- speaker = account.sonos_speaker("Kitchen")
-
- album = server.library.section('Music').get('Stevie Wonder').album('Innervisions')
-
- # Speaker control examples
- speaker.playMedia(album)
- speaker.pause()
- speaker.setVolume(10)
- speaker.skipNext()
-
-
-Running tests over PlexAPI
---------------------------
-
-Use:
-
-.. code-block:: bash
-
- tools/plex-boostraptest.py
-
-with appropriate
-arguments and add this new server to a shared user which username is defined in environment variable `SHARED_USERNAME`.
-It uses `official docker image`_ to create a proper instance.
-
-For skipping the docker and reuse a existing server use
-
-.. code-block:: bash
-
- python plex-bootstraptest.py --no-docker --username USERNAME --password PASSWORD --server-name NAME-OF-YOUR-SEVER
-
-Also in order to run most of the tests you have to provide some environment variables:
-
-* `PLEXAPI_AUTH_SERVER_BASEURL` containing an URL to your Plex instance, e.g. `http://127.0.0.1:32400` (without trailing
- slash)
-* `PLEXAPI_AUTH_MYPLEX_USERNAME` and `PLEXAPI_AUTH_MYPLEX_PASSWORD` with your MyPlex username and password accordingly
-
-After this step you can run tests with following command:
-
-.. code-block:: bash
-
- py.test tests -rxXs --ignore=tests/test_sync.py
-
-Some of the tests in main test-suite require a shared user in your account (e.g. `test_myplex_users`,
-`test_myplex_updateFriend`, etc.), you need to provide a valid shared user's username to get them running you need to
-provide the username of the shared user as an environment variable `SHARED_USERNAME`. You can enable a Guest account and
-simply pass `Guest` as `SHARED_USERNAME` (or just create a user like `plexapitest` and play with it).
-
-To be able to run tests over Mobile Sync api you have to some some more environment variables, to following values
-exactly:
-
-* PLEXAPI_HEADER_PROVIDES='controller,sync-target'
-* PLEXAPI_HEADER_PLATFORM=iOS
-* PLEXAPI_HEADER_PLATFORM_VERSION=11.4.1
-* PLEXAPI_HEADER_DEVICE=iPhone
-
-And finally run the sync-related tests:
-
-.. code-block:: bash
-
- py.test tests/test_sync.py -rxXs
-
-.. _official docker image: https://hub.docker.com/r/plexinc/pms-docker/
-
-Common Questions
-----------------
-
-**Why are you using camelCase and not following PEP8 guidelines?**
-
-This API reads XML documents provided by MyPlex and the Plex Server.
-We decided to conform to their style so that the API variable names directly
-match with the provided XML documents.
-
-
-**Why don't you offer feature XYZ?**
-
-This library is meant to be a wrapper around the XML pages the Plex
-server provides. If we are not providing an API that is offered in the
-XML pages, please let us know! -- Adding additional features beyond that
-should be done outside the scope of this library.
-
-
-**What are some helpful links if trying to understand the raw Plex API?**
-
-* https://github.com/plexinc/plex-media-player/wiki/Remote-control-API
-* https://forums.plex.tv/discussion/104353/pms-web-api-documentation
-* https://github.com/Arcanemagus/plex-api/wiki
diff --git a/libs/PlexAPI-4.16.1.dist-info/RECORD b/libs/PlexAPI-4.16.1.dist-info/RECORD
deleted file mode 100644
index 02ca75395e..0000000000
--- a/libs/PlexAPI-4.16.1.dist-info/RECORD
+++ /dev/null
@@ -1,31 +0,0 @@
-PlexAPI-4.16.1.dist-info/AUTHORS.txt,sha256=iEonabCDE0G6AnfT0tCcppsJ0AaTJZGhRjIM4lIIAck,228
-PlexAPI-4.16.1.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
-PlexAPI-4.16.1.dist-info/LICENSE.txt,sha256=ZmoFInlwd6lOpMMQCWIbjLLtu4pwhwWArg_dnYS3X5A,1515
-PlexAPI-4.16.1.dist-info/METADATA,sha256=Wqd-vI8B0Geygwyrt4NqBcSsuUZxoDxqBHbLtKjz6Wc,9284
-PlexAPI-4.16.1.dist-info/RECORD,,
-PlexAPI-4.16.1.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
-PlexAPI-4.16.1.dist-info/WHEEL,sha256=2wepM1nk4DS4eFpYrW1TTqPcoGNfHhhO_i5m4cOimbo,92
-PlexAPI-4.16.1.dist-info/top_level.txt,sha256=PTwXHiZDiXtrZnSI7lpZkRz1oJs5DyYpiiu_FuhuSlk,8
-plexapi/__init__.py,sha256=rsy6uvdxBP64y4v5lC4yLTP3l5VY3S-Rsk8rE_gDPIM,2144
-plexapi/alert.py,sha256=pSAIwtzsOnY2b97137dG_8YZOpSBxmKJ_kRz0oZw5jA,4065
-plexapi/audio.py,sha256=A6hI88X3nP2fTiXMMu7Y_-iRGOtr6K_iRJtw2yzuX6g,29505
-plexapi/base.py,sha256=aeCngmI8GHicvzIurfGoPFtAfFJXbMJzZ8b8Fi0d3yo,49100
-plexapi/client.py,sha256=IMbtTVes6_XFO6KBOtMqX4DDvY-iC-94lzb4wdIzYS8,27906
-plexapi/collection.py,sha256=Cv4xQQMY0YzfrD4FJirUwepPbq28CkJOiCPauNueaQQ,26267
-plexapi/config.py,sha256=1kiGaq-DooB9zy5KvMLls6_FyIyPIXwddh0zjcmpD-U,2696
-plexapi/const.py,sha256=0tyh_Wsx9JgzwWD0mCyTM6cLnFtukiMEhUqn9ibDSIU,239
-plexapi/exceptions.py,sha256=yQYnQk07EQcwvFGJ44rXPt9Q3L415BYqyxxOCj2R8CI,683
-plexapi/gdm.py,sha256=SVi6uZu5pCuLNUAPIm8WeIJy1J55NTtN-bsBnTvB6Ec,5066
-plexapi/library.py,sha256=ceJryNLApNus7MCmQ8nm4HuO2_UKBgTdD1EGVI-u6yA,143847
-plexapi/media.py,sha256=Gsx8IqSUF71Qg4fCVQiEdPcepc3-i0jlgowEVgZiAj0,56451
-plexapi/mixins.py,sha256=hICrNwbVznjPDibsQHiHXMQ2T4fDooszlR7U47wJ3QM,49090
-plexapi/myplex.py,sha256=AQR2ZHM045-OAF8JN-f4yKMwFFO0B1r_eQPBsgVW0ps,99769
-plexapi/photo.py,sha256=eOyn_0wbXLQ7r0zADWbRTfbuRv70_NNN1DViwG2nW24,15702
-plexapi/playlist.py,sha256=SABCcXfDs3fLE_N0rUwqAkKTbduscQ6cDGpGoutGsrU,24436
-plexapi/playqueue.py,sha256=MU8fZMyTNTZOIJuPkNSGXijDAGeAuAVAiurtGzVFxG0,12937
-plexapi/server.py,sha256=tojLUl4sJdu2qnCwu0f_kac5_LKVfEI9SN5qJ553tms,64062
-plexapi/settings.py,sha256=3suRjHsJUBeRG61WXLpjmNxoTiRFLJMcuZZbzkaDK_Q,7149
-plexapi/sonos.py,sha256=tIr216CC-o2Vk8GLxsNPkXeyq4JYs9pgz244wbbFfgA,5099
-plexapi/sync.py,sha256=1NK-oeUKVvNnLFIVAq8d8vy2jG8Nu4gkQB295Qx2xYE,13728
-plexapi/utils.py,sha256=BvcUNCm_lPnDo5ny4aRlLtVT6KobVG4EqwPjN4w3kAc,24246
-plexapi/video.py,sha256=9DUhtyA1KCwVN8IoJUfa_kUlZEbplWFCli6-D0nr__k,62939
diff --git a/libs/PlexAPI-4.16.1.dist-info/WHEEL b/libs/PlexAPI-4.16.1.dist-info/WHEEL
deleted file mode 100644
index 57e3d840d5..0000000000
--- a/libs/PlexAPI-4.16.1.dist-info/WHEEL
+++ /dev/null
@@ -1,5 +0,0 @@
-Wheel-Version: 1.0
-Generator: bdist_wheel (0.38.4)
-Root-Is-Purelib: true
-Tag: py3-none-any
-
diff --git a/libs/PySocks-1.7.1.dist-info/METADATA b/libs/PySocks-1.7.1.dist-info/METADATA
index ea990785ed..00e201fec4 100644
--- a/libs/PySocks-1.7.1.dist-info/METADATA
+++ b/libs/PySocks-1.7.1.dist-info/METADATA
@@ -1,4 +1,4 @@
-Metadata-Version: 2.1
+Metadata-Version: 2.4
Name: PySocks
Version: 1.7.1
Summary: A Python SOCKS client module. See https://github.com/Anorov/PySocks for more information.
@@ -16,6 +16,17 @@ Classifier: Programming Language :: Python :: 3.6
Requires-Python: >=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*
Description-Content-Type: text/markdown
License-File: LICENSE
+Dynamic: author
+Dynamic: author-email
+Dynamic: classifier
+Dynamic: description
+Dynamic: description-content-type
+Dynamic: home-page
+Dynamic: keywords
+Dynamic: license
+Dynamic: license-file
+Dynamic: requires-python
+Dynamic: summary
PySocks
=======
diff --git a/libs/PySocks-1.7.1.dist-info/RECORD b/libs/PySocks-1.7.1.dist-info/RECORD
index 3b5bbf3d16..6fc522a8b3 100644
--- a/libs/PySocks-1.7.1.dist-info/RECORD
+++ b/libs/PySocks-1.7.1.dist-info/RECORD
@@ -1,9 +1,9 @@
-PySocks-1.7.1.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
-PySocks-1.7.1.dist-info/LICENSE,sha256=cCfiFOAU63i3rcwc7aWspxOnn8T2oMUsnaWz5wfm_-k,1401
-PySocks-1.7.1.dist-info/METADATA,sha256=RThVWnkrwm4fr1ITwGmvqqDXAYxHZG_WIoyRdQTBk4g,13237
-PySocks-1.7.1.dist-info/RECORD,,
-PySocks-1.7.1.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
-PySocks-1.7.1.dist-info/WHEEL,sha256=Xo9-1PvkuimrydujYJAjF7pCkriuXBpUPEjma1nZyJ0,92
-PySocks-1.7.1.dist-info/top_level.txt,sha256=TKSOIfCFBoK9EY8FBYbYqC3PWd3--G15ph9n8-QHPDk,19
+pysocks-1.7.1.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
+pysocks-1.7.1.dist-info/METADATA,sha256=s0tCSaT0WJRL6NNS7WXhn9ua6ECBV8DbCiIuNMaA9gk,13468
+pysocks-1.7.1.dist-info/RECORD,,
+pysocks-1.7.1.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
+pysocks-1.7.1.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
+pysocks-1.7.1.dist-info/licenses/LICENSE,sha256=cCfiFOAU63i3rcwc7aWspxOnn8T2oMUsnaWz5wfm_-k,1401
+pysocks-1.7.1.dist-info/top_level.txt,sha256=TKSOIfCFBoK9EY8FBYbYqC3PWd3--G15ph9n8-QHPDk,19
socks.py,sha256=xOYn27t9IGrbTBzWsUUuPa0YBuplgiUykzkOB5V5iFY,31086
sockshandler.py,sha256=2SYGj-pwt1kjgLoZAmyeaEXCeZDWRmfVS_QG6kErGtY,3966
diff --git a/libs/PySocks-1.7.1.dist-info/WHEEL b/libs/PySocks-1.7.1.dist-info/WHEEL
index ba48cbcf92..0885d05555 100644
--- a/libs/PySocks-1.7.1.dist-info/WHEEL
+++ b/libs/PySocks-1.7.1.dist-info/WHEEL
@@ -1,5 +1,5 @@
Wheel-Version: 1.0
-Generator: bdist_wheel (0.41.3)
+Generator: setuptools (80.10.2)
Root-Is-Purelib: true
Tag: py3-none-any
diff --git a/libs/PySocks-1.7.1.dist-info/LICENSE b/libs/PySocks-1.7.1.dist-info/licenses/LICENSE
similarity index 100%
rename from libs/PySocks-1.7.1.dist-info/LICENSE
rename to libs/PySocks-1.7.1.dist-info/licenses/LICENSE
diff --git a/libs/PyYAML-6.0.2.dist-info/METADATA b/libs/PyYAML-6.0.2.dist-info/METADATA
deleted file mode 100644
index db029b770c..0000000000
--- a/libs/PyYAML-6.0.2.dist-info/METADATA
+++ /dev/null
@@ -1,46 +0,0 @@
-Metadata-Version: 2.1
-Name: PyYAML
-Version: 6.0.2
-Summary: YAML parser and emitter for Python
-Home-page: https://pyyaml.org/
-Download-URL: https://pypi.org/project/PyYAML/
-Author: Kirill Simonov
-Author-email: xi@resolvent.net
-License: MIT
-Project-URL: Bug Tracker, https://github.com/yaml/pyyaml/issues
-Project-URL: CI, https://github.com/yaml/pyyaml/actions
-Project-URL: Documentation, https://pyyaml.org/wiki/PyYAMLDocumentation
-Project-URL: Mailing lists, http://lists.sourceforge.net/lists/listinfo/yaml-core
-Project-URL: Source Code, https://github.com/yaml/pyyaml
-Platform: Any
-Classifier: Development Status :: 5 - Production/Stable
-Classifier: Intended Audience :: Developers
-Classifier: License :: OSI Approved :: MIT License
-Classifier: Operating System :: OS Independent
-Classifier: Programming Language :: Cython
-Classifier: Programming Language :: Python
-Classifier: Programming Language :: Python :: 3
-Classifier: Programming Language :: Python :: 3.8
-Classifier: Programming Language :: Python :: 3.9
-Classifier: Programming Language :: Python :: 3.10
-Classifier: Programming Language :: Python :: 3.11
-Classifier: Programming Language :: Python :: 3.12
-Classifier: Programming Language :: Python :: 3.13
-Classifier: Programming Language :: Python :: Implementation :: CPython
-Classifier: Programming Language :: Python :: Implementation :: PyPy
-Classifier: Topic :: Software Development :: Libraries :: Python Modules
-Classifier: Topic :: Text Processing :: Markup
-Requires-Python: >=3.8
-License-File: LICENSE
-
-YAML is a data serialization format designed for human readability
-and interaction with scripting languages. PyYAML is a YAML parser
-and emitter for Python.
-
-PyYAML features a complete YAML 1.1 parser, Unicode support, pickle
-support, capable extension API, and sensible error messages. PyYAML
-supports standard YAML tags and provides Python-specific tags that
-allow to represent an arbitrary Python object.
-
-PyYAML is applicable for a broad range of tasks from complex
-configuration files to object serialization and persistence.
diff --git a/libs/PyYAML-6.0.2.dist-info/RECORD b/libs/PyYAML-6.0.2.dist-info/RECORD
deleted file mode 100644
index f01fe76227..0000000000
--- a/libs/PyYAML-6.0.2.dist-info/RECORD
+++ /dev/null
@@ -1,25 +0,0 @@
-PyYAML-6.0.2.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
-PyYAML-6.0.2.dist-info/LICENSE,sha256=jTko-dxEkP1jVwfLiOsmvXZBAqcoKVQwfT5RZ6V36KQ,1101
-PyYAML-6.0.2.dist-info/METADATA,sha256=9-odFB5seu4pGPcEv7E8iyxNF51_uKnaNGjLAhz2lto,2060
-PyYAML-6.0.2.dist-info/RECORD,,
-PyYAML-6.0.2.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
-PyYAML-6.0.2.dist-info/WHEEL,sha256=39uaw0gKzAUihvDPhgMAk_aKXc5F8smdVlzAUVAVruU,109
-PyYAML-6.0.2.dist-info/top_level.txt,sha256=rpj0IVMTisAjh_1vG3Ccf9v5jpCQwAz6cD1IVU5ZdhQ,11
-_yaml/__init__.py,sha256=04Ae_5osxahpJHa3XBZUAf4wi6XX32gR8D6X6p64GEA,1402
-yaml/__init__.py,sha256=N35S01HMesFTe0aRRMWkPj0Pa8IEbHpE9FK7cr5Bdtw,12311
-yaml/composer.py,sha256=_Ko30Wr6eDWUeUpauUGT3Lcg9QPBnOPVlTnIMRGJ9FM,4883
-yaml/constructor.py,sha256=kNgkfaeLUkwQYY_Q6Ff1Tz2XVw_pG1xVE9Ak7z-viLA,28639
-yaml/cyaml.py,sha256=6ZrAG9fAYvdVe2FK_w0hmXoG7ZYsoYUwapG8CiC72H0,3851
-yaml/dumper.py,sha256=PLctZlYwZLp7XmeUdwRuv4nYOZ2UBnDIUy8-lKfLF-o,2837
-yaml/emitter.py,sha256=jghtaU7eFwg31bG0B7RZea_29Adi9CKmXq_QjgQpCkQ,43006
-yaml/error.py,sha256=Ah9z-toHJUbE9j-M8YpxgSRM5CgLCcwVzJgLLRF2Fxo,2533
-yaml/events.py,sha256=50_TksgQiE4up-lKo_V-nBy-tAIxkIPQxY5qDhKCeHw,2445
-yaml/loader.py,sha256=UVa-zIqmkFSCIYq_PgSGm4NSJttHY2Rf_zQ4_b1fHN0,2061
-yaml/nodes.py,sha256=gPKNj8pKCdh2d4gr3gIYINnPOaOxGhJAUiYhGRnPE84,1440
-yaml/parser.py,sha256=ilWp5vvgoHFGzvOZDItFoGjD6D42nhlZrZyjAwa0oJo,25495
-yaml/reader.py,sha256=0dmzirOiDG4Xo41RnuQS7K9rkY3xjHiVasfDMNTqCNw,6794
-yaml/representer.py,sha256=IuWP-cAW9sHKEnS0gCqSa894k1Bg4cgTxaDwIcbRQ-Y,14190
-yaml/resolver.py,sha256=9L-VYfm4mWHxUD1Vg4X7rjDRK_7VZd6b92wzq7Y2IKY,9004
-yaml/scanner.py,sha256=YEM3iLZSaQwXcQRg2l2R4MdT0zGP2F9eHkKGKnHyWQY,51279
-yaml/serializer.py,sha256=ChuFgmhU01hj4xgI8GaKv6vfM2Bujwa9i7d2FAHj7cA,4165
-yaml/tokens.py,sha256=lTQIzSVw8Mg9wv459-TjiOQe6wVziqaRlqX2_89rp54,2573
diff --git a/libs/PyYAML-6.0.2.dist-info/WHEEL b/libs/PyYAML-6.0.2.dist-info/WHEEL
deleted file mode 100644
index b8b9cfd4ca..0000000000
--- a/libs/PyYAML-6.0.2.dist-info/WHEEL
+++ /dev/null
@@ -1,5 +0,0 @@
-Wheel-Version: 1.0
-Generator: bdist_wheel (0.44.0)
-Root-Is-Purelib: false
-Tag: cp38-cp38-macosx_12_0_x86_64
-
diff --git a/libs/APScheduler-3.10.4.dist-info/INSTALLER b/libs/PyYAML-6.0.3.dist-info/INSTALLER
similarity index 100%
rename from libs/APScheduler-3.10.4.dist-info/INSTALLER
rename to libs/PyYAML-6.0.3.dist-info/INSTALLER
diff --git a/libs/PyYAML-6.0.2.dist-info/LICENSE b/libs/PyYAML-6.0.3.dist-info/LICENSE
similarity index 100%
rename from libs/PyYAML-6.0.2.dist-info/LICENSE
rename to libs/PyYAML-6.0.3.dist-info/LICENSE
diff --git a/libs/PyYAML-6.0.3.dist-info/METADATA b/libs/PyYAML-6.0.3.dist-info/METADATA
new file mode 100644
index 0000000000..330ffe9df9
--- /dev/null
+++ b/libs/PyYAML-6.0.3.dist-info/METADATA
@@ -0,0 +1,59 @@
+Metadata-Version: 2.1
+Name: PyYAML
+Version: 6.0.3
+Summary: YAML parser and emitter for Python
+Home-page: https://pyyaml.org/
+Download-URL: https://pypi.org/project/PyYAML/
+Author: Kirill Simonov
+Author-email: xi@resolvent.net
+License: MIT
+Project-URL: Bug Tracker, https://github.com/yaml/pyyaml/issues
+Project-URL: CI, https://github.com/yaml/pyyaml/actions
+Project-URL: Documentation, https://pyyaml.org/wiki/PyYAMLDocumentation
+Project-URL: Mailing lists, http://lists.sourceforge.net/lists/listinfo/yaml-core
+Project-URL: Source Code, https://github.com/yaml/pyyaml
+Platform: Any
+Classifier: Development Status :: 5 - Production/Stable
+Classifier: Intended Audience :: Developers
+Classifier: License :: OSI Approved :: MIT License
+Classifier: Operating System :: OS Independent
+Classifier: Programming Language :: Cython
+Classifier: Programming Language :: Python
+Classifier: Programming Language :: Python :: 3
+Classifier: Programming Language :: Python :: 3.8
+Classifier: Programming Language :: Python :: 3.9
+Classifier: Programming Language :: Python :: 3.10
+Classifier: Programming Language :: Python :: 3.11
+Classifier: Programming Language :: Python :: 3.12
+Classifier: Programming Language :: Python :: 3.13
+Classifier: Programming Language :: Python :: 3.14
+Classifier: Programming Language :: Python :: Implementation :: CPython
+Classifier: Programming Language :: Python :: Implementation :: PyPy
+Classifier: Topic :: Software Development :: Libraries :: Python Modules
+Classifier: Topic :: Text Processing :: Markup
+Requires-Python: >=3.8
+License-File: LICENSE
+Dynamic: author
+Dynamic: author-email
+Dynamic: classifier
+Dynamic: description
+Dynamic: download-url
+Dynamic: home-page
+Dynamic: license
+Dynamic: license-file
+Dynamic: platform
+Dynamic: project-url
+Dynamic: requires-python
+Dynamic: summary
+
+YAML is a data serialization format designed for human readability
+and interaction with scripting languages. PyYAML is a YAML parser
+and emitter for Python.
+
+PyYAML features a complete YAML 1.1 parser, Unicode support, pickle
+support, capable extension API, and sensible error messages. PyYAML
+supports standard YAML tags and provides Python-specific tags that
+allow to represent an arbitrary Python object.
+
+PyYAML is applicable for a broad range of tasks from complex
+configuration files to object serialization and persistence.
diff --git a/libs/PyYAML-6.0.3.dist-info/RECORD b/libs/PyYAML-6.0.3.dist-info/RECORD
new file mode 100644
index 0000000000..f5113593a2
--- /dev/null
+++ b/libs/PyYAML-6.0.3.dist-info/RECORD
@@ -0,0 +1,25 @@
+PyYAML-6.0.3.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
+PyYAML-6.0.3.dist-info/LICENSE,sha256=jTko-dxEkP1jVwfLiOsmvXZBAqcoKVQwfT5RZ6V36KQ,1101
+PyYAML-6.0.3.dist-info/METADATA,sha256=flU4VTkFLVOleKPdLjqz_9sAdVG3dXdBfUSdeA5B4Tk,2351
+PyYAML-6.0.3.dist-info/RECORD,,
+PyYAML-6.0.3.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
+PyYAML-6.0.3.dist-info/WHEEL,sha256=hFYUutZ2FUL_WAhfJuJKZaElTlo_M4xZvGUwp8hfNTA,110
+PyYAML-6.0.3.dist-info/top_level.txt,sha256=rpj0IVMTisAjh_1vG3Ccf9v5jpCQwAz6cD1IVU5ZdhQ,11
+_yaml/__init__.py,sha256=04Ae_5osxahpJHa3XBZUAf4wi6XX32gR8D6X6p64GEA,1402
+yaml/__init__.py,sha256=sZ38wzPWp139cwc5ARZFByUvJxtB07X32FUQAzoFR6c,12311
+yaml/composer.py,sha256=_Ko30Wr6eDWUeUpauUGT3Lcg9QPBnOPVlTnIMRGJ9FM,4883
+yaml/constructor.py,sha256=kNgkfaeLUkwQYY_Q6Ff1Tz2XVw_pG1xVE9Ak7z-viLA,28639
+yaml/cyaml.py,sha256=6ZrAG9fAYvdVe2FK_w0hmXoG7ZYsoYUwapG8CiC72H0,3851
+yaml/dumper.py,sha256=PLctZlYwZLp7XmeUdwRuv4nYOZ2UBnDIUy8-lKfLF-o,2837
+yaml/emitter.py,sha256=jghtaU7eFwg31bG0B7RZea_29Adi9CKmXq_QjgQpCkQ,43006
+yaml/error.py,sha256=Ah9z-toHJUbE9j-M8YpxgSRM5CgLCcwVzJgLLRF2Fxo,2533
+yaml/events.py,sha256=50_TksgQiE4up-lKo_V-nBy-tAIxkIPQxY5qDhKCeHw,2445
+yaml/loader.py,sha256=UVa-zIqmkFSCIYq_PgSGm4NSJttHY2Rf_zQ4_b1fHN0,2061
+yaml/nodes.py,sha256=gPKNj8pKCdh2d4gr3gIYINnPOaOxGhJAUiYhGRnPE84,1440
+yaml/parser.py,sha256=ilWp5vvgoHFGzvOZDItFoGjD6D42nhlZrZyjAwa0oJo,25495
+yaml/reader.py,sha256=0dmzirOiDG4Xo41RnuQS7K9rkY3xjHiVasfDMNTqCNw,6794
+yaml/representer.py,sha256=IuWP-cAW9sHKEnS0gCqSa894k1Bg4cgTxaDwIcbRQ-Y,14190
+yaml/resolver.py,sha256=9L-VYfm4mWHxUD1Vg4X7rjDRK_7VZd6b92wzq7Y2IKY,9004
+yaml/scanner.py,sha256=YEM3iLZSaQwXcQRg2l2R4MdT0zGP2F9eHkKGKnHyWQY,51279
+yaml/serializer.py,sha256=ChuFgmhU01hj4xgI8GaKv6vfM2Bujwa9i7d2FAHj7cA,4165
+yaml/tokens.py,sha256=lTQIzSVw8Mg9wv459-TjiOQe6wVziqaRlqX2_89rp54,2573
diff --git a/libs/APScheduler-3.10.4.dist-info/REQUESTED b/libs/PyYAML-6.0.3.dist-info/REQUESTED
similarity index 100%
rename from libs/APScheduler-3.10.4.dist-info/REQUESTED
rename to libs/PyYAML-6.0.3.dist-info/REQUESTED
diff --git a/libs/PyYAML-6.0.3.dist-info/WHEEL b/libs/PyYAML-6.0.3.dist-info/WHEEL
new file mode 100644
index 0000000000..4cfaefcad6
--- /dev/null
+++ b/libs/PyYAML-6.0.3.dist-info/WHEEL
@@ -0,0 +1,5 @@
+Wheel-Version: 1.0
+Generator: bdist_wheel (0.44.0)
+Root-Is-Purelib: false
+Tag: cp310-cp310-macosx_14_0_arm64
+
diff --git a/libs/PyYAML-6.0.2.dist-info/top_level.txt b/libs/PyYAML-6.0.3.dist-info/top_level.txt
similarity index 100%
rename from libs/PyYAML-6.0.2.dist-info/top_level.txt
rename to libs/PyYAML-6.0.3.dist-info/top_level.txt
diff --git a/libs/SQLAlchemy-2.0.37.dist-info/LICENSE b/libs/SQLAlchemy-2.0.37.dist-info/LICENSE
deleted file mode 100644
index dfe1a4d815..0000000000
--- a/libs/SQLAlchemy-2.0.37.dist-info/LICENSE
+++ /dev/null
@@ -1,19 +0,0 @@
-Copyright 2005-2025 SQLAlchemy authors and contributors .
-
-Permission is hereby granted, free of charge, to any person obtaining a copy of
-this software and associated documentation files (the "Software"), to deal in
-the Software without restriction, including without limitation the rights to
-use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
-of the Software, and to permit persons to whom the Software is furnished to do
-so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
diff --git a/libs/SQLAlchemy-2.0.37.dist-info/METADATA b/libs/SQLAlchemy-2.0.37.dist-info/METADATA
deleted file mode 100644
index e548f29da2..0000000000
--- a/libs/SQLAlchemy-2.0.37.dist-info/METADATA
+++ /dev/null
@@ -1,243 +0,0 @@
-Metadata-Version: 2.1
-Name: SQLAlchemy
-Version: 2.0.37
-Summary: Database Abstraction Library
-Home-page: https://www.sqlalchemy.org
-Author: Mike Bayer
-Author-email: mike_mp@zzzcomputing.com
-License: MIT
-Project-URL: Documentation, https://docs.sqlalchemy.org
-Project-URL: Issue Tracker, https://github.com/sqlalchemy/sqlalchemy/
-Classifier: Development Status :: 5 - Production/Stable
-Classifier: Intended Audience :: Developers
-Classifier: License :: OSI Approved :: MIT License
-Classifier: Operating System :: OS Independent
-Classifier: Programming Language :: Python
-Classifier: Programming Language :: Python :: 3
-Classifier: Programming Language :: Python :: 3.7
-Classifier: Programming Language :: Python :: 3.8
-Classifier: Programming Language :: Python :: 3.9
-Classifier: Programming Language :: Python :: 3.10
-Classifier: Programming Language :: Python :: 3.11
-Classifier: Programming Language :: Python :: 3.12
-Classifier: Programming Language :: Python :: 3.13
-Classifier: Programming Language :: Python :: Implementation :: CPython
-Classifier: Programming Language :: Python :: Implementation :: PyPy
-Classifier: Topic :: Database :: Front-Ends
-Requires-Python: >=3.7
-Description-Content-Type: text/x-rst
-License-File: LICENSE
-Requires-Dist: typing-extensions >=4.6.0
-Requires-Dist: greenlet !=0.4.17 ; python_version < "3.14" and (platform_machine == "aarch64" or (platform_machine == "ppc64le" or (platform_machine == "x86_64" or (platform_machine == "amd64" or (platform_machine == "AMD64" or (platform_machine == "win32" or platform_machine == "WIN32"))))))
-Requires-Dist: importlib-metadata ; python_version < "3.8"
-Provides-Extra: aiomysql
-Requires-Dist: greenlet !=0.4.17 ; extra == 'aiomysql'
-Requires-Dist: aiomysql >=0.2.0 ; extra == 'aiomysql'
-Provides-Extra: aioodbc
-Requires-Dist: greenlet !=0.4.17 ; extra == 'aioodbc'
-Requires-Dist: aioodbc ; extra == 'aioodbc'
-Provides-Extra: aiosqlite
-Requires-Dist: greenlet !=0.4.17 ; extra == 'aiosqlite'
-Requires-Dist: aiosqlite ; extra == 'aiosqlite'
-Requires-Dist: typing-extensions !=3.10.0.1 ; extra == 'aiosqlite'
-Provides-Extra: asyncio
-Requires-Dist: greenlet !=0.4.17 ; extra == 'asyncio'
-Provides-Extra: asyncmy
-Requires-Dist: greenlet !=0.4.17 ; extra == 'asyncmy'
-Requires-Dist: asyncmy !=0.2.4,!=0.2.6,>=0.2.3 ; extra == 'asyncmy'
-Provides-Extra: mariadb_connector
-Requires-Dist: mariadb !=1.1.10,!=1.1.2,!=1.1.5,>=1.0.1 ; extra == 'mariadb_connector'
-Provides-Extra: mssql
-Requires-Dist: pyodbc ; extra == 'mssql'
-Provides-Extra: mssql_pymssql
-Requires-Dist: pymssql ; extra == 'mssql_pymssql'
-Provides-Extra: mssql_pyodbc
-Requires-Dist: pyodbc ; extra == 'mssql_pyodbc'
-Provides-Extra: mypy
-Requires-Dist: mypy >=0.910 ; extra == 'mypy'
-Provides-Extra: mysql
-Requires-Dist: mysqlclient >=1.4.0 ; extra == 'mysql'
-Provides-Extra: mysql_connector
-Requires-Dist: mysql-connector-python ; extra == 'mysql_connector'
-Provides-Extra: oracle
-Requires-Dist: cx-oracle >=8 ; extra == 'oracle'
-Provides-Extra: oracle_oracledb
-Requires-Dist: oracledb >=1.0.1 ; extra == 'oracle_oracledb'
-Provides-Extra: postgresql
-Requires-Dist: psycopg2 >=2.7 ; extra == 'postgresql'
-Provides-Extra: postgresql_asyncpg
-Requires-Dist: greenlet !=0.4.17 ; extra == 'postgresql_asyncpg'
-Requires-Dist: asyncpg ; extra == 'postgresql_asyncpg'
-Provides-Extra: postgresql_pg8000
-Requires-Dist: pg8000 >=1.29.1 ; extra == 'postgresql_pg8000'
-Provides-Extra: postgresql_psycopg
-Requires-Dist: psycopg >=3.0.7 ; extra == 'postgresql_psycopg'
-Provides-Extra: postgresql_psycopg2binary
-Requires-Dist: psycopg2-binary ; extra == 'postgresql_psycopg2binary'
-Provides-Extra: postgresql_psycopg2cffi
-Requires-Dist: psycopg2cffi ; extra == 'postgresql_psycopg2cffi'
-Provides-Extra: postgresql_psycopgbinary
-Requires-Dist: psycopg[binary] >=3.0.7 ; extra == 'postgresql_psycopgbinary'
-Provides-Extra: pymysql
-Requires-Dist: pymysql ; extra == 'pymysql'
-Provides-Extra: sqlcipher
-Requires-Dist: sqlcipher3-binary ; extra == 'sqlcipher'
-
-SQLAlchemy
-==========
-
-|PyPI| |Python| |Downloads|
-
-.. |PyPI| image:: https://img.shields.io/pypi/v/sqlalchemy
- :target: https://pypi.org/project/sqlalchemy
- :alt: PyPI
-
-.. |Python| image:: https://img.shields.io/pypi/pyversions/sqlalchemy
- :target: https://pypi.org/project/sqlalchemy
- :alt: PyPI - Python Version
-
-.. |Downloads| image:: https://static.pepy.tech/badge/sqlalchemy/month
- :target: https://pepy.tech/project/sqlalchemy
- :alt: PyPI - Downloads
-
-
-The Python SQL Toolkit and Object Relational Mapper
-
-Introduction
--------------
-
-SQLAlchemy is the Python SQL toolkit and Object Relational Mapper
-that gives application developers the full power and
-flexibility of SQL. SQLAlchemy provides a full suite
-of well known enterprise-level persistence patterns,
-designed for efficient and high-performing database
-access, adapted into a simple and Pythonic domain
-language.
-
-Major SQLAlchemy features include:
-
-* An industrial strength ORM, built
- from the core on the identity map, unit of work,
- and data mapper patterns. These patterns
- allow transparent persistence of objects
- using a declarative configuration system.
- Domain models
- can be constructed and manipulated naturally,
- and changes are synchronized with the
- current transaction automatically.
-* A relationally-oriented query system, exposing
- the full range of SQL's capabilities
- explicitly, including joins, subqueries,
- correlation, and most everything else,
- in terms of the object model.
- Writing queries with the ORM uses the same
- techniques of relational composition you use
- when writing SQL. While you can drop into
- literal SQL at any time, it's virtually never
- needed.
-* A comprehensive and flexible system
- of eager loading for related collections and objects.
- Collections are cached within a session,
- and can be loaded on individual access, all
- at once using joins, or by query per collection
- across the full result set.
-* A Core SQL construction system and DBAPI
- interaction layer. The SQLAlchemy Core is
- separate from the ORM and is a full database
- abstraction layer in its own right, and includes
- an extensible Python-based SQL expression
- language, schema metadata, connection pooling,
- type coercion, and custom types.
-* All primary and foreign key constraints are
- assumed to be composite and natural. Surrogate
- integer primary keys are of course still the
- norm, but SQLAlchemy never assumes or hardcodes
- to this model.
-* Database introspection and generation. Database
- schemas can be "reflected" in one step into
- Python structures representing database metadata;
- those same structures can then generate
- CREATE statements right back out - all within
- the Core, independent of the ORM.
-
-SQLAlchemy's philosophy:
-
-* SQL databases behave less and less like object
- collections the more size and performance start to
- matter; object collections behave less and less like
- tables and rows the more abstraction starts to matter.
- SQLAlchemy aims to accommodate both of these
- principles.
-* An ORM doesn't need to hide the "R". A relational
- database provides rich, set-based functionality
- that should be fully exposed. SQLAlchemy's
- ORM provides an open-ended set of patterns
- that allow a developer to construct a custom
- mediation layer between a domain model and
- a relational schema, turning the so-called
- "object relational impedance" issue into
- a distant memory.
-* The developer, in all cases, makes all decisions
- regarding the design, structure, and naming conventions
- of both the object model as well as the relational
- schema. SQLAlchemy only provides the means
- to automate the execution of these decisions.
-* With SQLAlchemy, there's no such thing as
- "the ORM generated a bad query" - you
- retain full control over the structure of
- queries, including how joins are organized,
- how subqueries and correlation is used, what
- columns are requested. Everything SQLAlchemy
- does is ultimately the result of a developer-initiated
- decision.
-* Don't use an ORM if the problem doesn't need one.
- SQLAlchemy consists of a Core and separate ORM
- component. The Core offers a full SQL expression
- language that allows Pythonic construction
- of SQL constructs that render directly to SQL
- strings for a target database, returning
- result sets that are essentially enhanced DBAPI
- cursors.
-* Transactions should be the norm. With SQLAlchemy's
- ORM, nothing goes to permanent storage until
- commit() is called. SQLAlchemy encourages applications
- to create a consistent means of delineating
- the start and end of a series of operations.
-* Never render a literal value in a SQL statement.
- Bound parameters are used to the greatest degree
- possible, allowing query optimizers to cache
- query plans effectively and making SQL injection
- attacks a non-issue.
-
-Documentation
--------------
-
-Latest documentation is at:
-
-https://www.sqlalchemy.org/docs/
-
-Installation / Requirements
----------------------------
-
-Full documentation for installation is at
-`Installation `_.
-
-Getting Help / Development / Bug reporting
-------------------------------------------
-
-Please refer to the `SQLAlchemy Community Guide `_.
-
-Code of Conduct
----------------
-
-Above all, SQLAlchemy places great emphasis on polite, thoughtful, and
-constructive communication between users and developers.
-Please see our current Code of Conduct at
-`Code of Conduct `_.
-
-License
--------
-
-SQLAlchemy is distributed under the `MIT license
-`_.
-
diff --git a/libs/SQLAlchemy-2.0.37.dist-info/RECORD b/libs/SQLAlchemy-2.0.37.dist-info/RECORD
deleted file mode 100644
index d9bb0e04fe..0000000000
--- a/libs/SQLAlchemy-2.0.37.dist-info/RECORD
+++ /dev/null
@@ -1,275 +0,0 @@
-SQLAlchemy-2.0.37.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
-SQLAlchemy-2.0.37.dist-info/LICENSE,sha256=mCFyC1jUpWW2EyEAeorUOraZGjlZ5mzV203Z6uacffw,1100
-SQLAlchemy-2.0.37.dist-info/METADATA,sha256=UywKCGKcABKNtpI-G6qnmmxpFaI6iJcHIDeLUQ2RvWQ,9692
-SQLAlchemy-2.0.37.dist-info/RECORD,,
-SQLAlchemy-2.0.37.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
-SQLAlchemy-2.0.37.dist-info/WHEEL,sha256=j8_MCNBI7KgztMI2VCVmNiYcEly_P_7tu-qcbaOXbrw,108
-SQLAlchemy-2.0.37.dist-info/top_level.txt,sha256=rp-ZgB7D8G11ivXON5VGPjupT1voYmWqkciDt5Uaw_Q,11
-sqlalchemy/__init__.py,sha256=m8AoRzqL1l_3uFAeJ_vwtlAfXkboxLKJ3oL1RqFnXbM,13033
-sqlalchemy/connectors/__init__.py,sha256=YeSHsOB0YhdM6jZUvHFQFwKqNXO02MlklmGW0yCywjI,476
-sqlalchemy/connectors/aioodbc.py,sha256=KT9xi2xQ4AJgDiGPTV5h_5qi9dummmenKAvWelwza3w,5288
-sqlalchemy/connectors/asyncio.py,sha256=00claZADdFUh2iQmlpqoLhLTBxK0i79Mwd9WZqUtleM,6138
-sqlalchemy/connectors/pyodbc.py,sha256=GsW9bD0H30OMTbGDx9SdaTT_ujgpxP7TM4rfhIzD4mo,8501
-sqlalchemy/cyextension/__init__.py,sha256=4npVIjitKfUs0NQ6f3UdQBDq4ipJ0_ZNB2mpKqtc5ik,244
-sqlalchemy/cyextension/collections.cpython-38-darwin.so,sha256=sEF81qKQrUMYUNYDc5rR0o9KN5ntUi5sU3VFXT_Gbs4,233760
-sqlalchemy/cyextension/collections.pyx,sha256=L7DZ3DGKpgw2MT2ZZRRxCnrcyE5pU1NAFowWgAzQPEc,12571
-sqlalchemy/cyextension/immutabledict.cpython-38-darwin.so,sha256=zPUP2rtm01kkegS3n-rYzu7VgmZNo1hQS65vgIUme4U,94672
-sqlalchemy/cyextension/immutabledict.pxd,sha256=3x3-rXG5eRQ7bBnktZ-OJ9-6ft8zToPmTDOd92iXpB0,291
-sqlalchemy/cyextension/immutabledict.pyx,sha256=KfDTYbTfebstE8xuqAtuXsHNAK0_b5q_ymUiinUe_xs,3535
-sqlalchemy/cyextension/processors.cpython-38-darwin.so,sha256=ZXviRx-dUBLWF5Z2xb2ImYGZR2s1VEEX0rvOvBihfYk,75864
-sqlalchemy/cyextension/processors.pyx,sha256=R1rHsGLEaGeBq5VeCydjClzYlivERIJ9B-XLOJlf2MQ,1792
-sqlalchemy/cyextension/resultproxy.cpython-38-darwin.so,sha256=uA4vRLlgWg8sEjeTXmJ5v4DAxpCubWTNMJGDRgtlgzY,78016
-sqlalchemy/cyextension/resultproxy.pyx,sha256=eWLdyBXiBy_CLQrF5ScfWJm7X0NeelscSXedtj1zv9Q,2725
-sqlalchemy/cyextension/util.cpython-38-darwin.so,sha256=BA75sGnaj5sISpo7_70Id7LycML4iHxagdVolVa8Mf4,93008
-sqlalchemy/cyextension/util.pyx,sha256=B85orxa9LddLuQEaDoVSq1XmAXIbLKxrxpvuB8ogV_o,2530
-sqlalchemy/dialects/__init__.py,sha256=4jxiSgI_fVCNXcz42gQYKEp0k07RAHyQN4ZpjaNsFUI,1770
-sqlalchemy/dialects/_typing.py,sha256=8YwrkOa8IvmBojwwegbL5mL_0UAuzdqYiKHKANpvHMw,971
-sqlalchemy/dialects/mssql/__init__.py,sha256=6t_aNpgbMLdPE9gpHYTf9o6QfVavncztRLbr21l2NaY,1880
-sqlalchemy/dialects/mssql/aioodbc.py,sha256=4CmhwIkZrabpG-r7_ogRVajD-nhRZSFJ0Swz2d0jIHM,2021
-sqlalchemy/dialects/mssql/base.py,sha256=2UCotpN3WBPgMddhXVP6Epc-srvNrYHCnK4kcEbjW6w,132713
-sqlalchemy/dialects/mssql/information_schema.py,sha256=v5MZz1FN72THEwF_u3Eh_2vnWdFE13RYydOioMMcuvU,8084
-sqlalchemy/dialects/mssql/json.py,sha256=F53pibuOVRzgDtjoclOI7LnkKXNVsaVfJyBH1XAhyDo,4756
-sqlalchemy/dialects/mssql/provision.py,sha256=P1tqxZ4f6Oeqn2gNi7dXl82LRLCg1-OB4eWiZc6CHek,5593
-sqlalchemy/dialects/mssql/pymssql.py,sha256=C7yAs3Pw81W1KTVNc6_0sHQuYlJ5iH82vKByY4TkB1g,4097
-sqlalchemy/dialects/mssql/pyodbc.py,sha256=CnO7KDWxbxb7AoZhp_PMDBvVSMuzwq1h4Cav2IWFWDo,27173
-sqlalchemy/dialects/mysql/__init__.py,sha256=ropOMUWrAcL-Q7h-9jQ_tb3ISAFIsNRQ8YVXvn0URl0,2206
-sqlalchemy/dialects/mysql/aiomysql.py,sha256=yrujoFtAG0QvtVlgbGBUMg3kXeXlIH62tvyYTCMUfnE,10013
-sqlalchemy/dialects/mysql/asyncmy.py,sha256=rmVSf86VYxgAUROIKfVtvS-grG9aPBiLY_Gu0KJMjuo,10081
-sqlalchemy/dialects/mysql/base.py,sha256=LkGJ6G1U2xygOawOtQYBfTipGh8MuiE1kNxaD7S9UIY,123432
-sqlalchemy/dialects/mysql/cymysql.py,sha256=KwxSsF4a6uUd6yblhSns8uj4hgmhv4hFInTZNdmRixA,2300
-sqlalchemy/dialects/mysql/dml.py,sha256=VjnTobe_SBNF2RN6tvqa5LOn-9x4teVUyzUedZkOmdc,7768
-sqlalchemy/dialects/mysql/enumerated.py,sha256=qI5gnBYhxk9dhPeUfGiijp0qT2Puazdp27-ba_38uWQ,8447
-sqlalchemy/dialects/mysql/expression.py,sha256=3PEKPwYIZ8mVXkjUgHaj_efPBYuBNWZSnfUcJuoZddA,4121
-sqlalchemy/dialects/mysql/json.py,sha256=W31DojiRypifXKVh3PJSWP7IHqFoeKwzLl-0CJH6QRI,2269
-sqlalchemy/dialects/mysql/mariadb.py,sha256=g4v4WQuXHn556Nn6k-RgvPrmfCql1R46fIEk6UEx0U8,1450
-sqlalchemy/dialects/mysql/mariadbconnector.py,sha256=t4m6kfYBoURjNXRxlEsRajjvArNDc4lmaFGxHQh7VTo,8623
-sqlalchemy/dialects/mysql/mysqlconnector.py,sha256=gdNOGdRqvnCbLZpKjpubu_0tGRQ5Tn_2TZvbp3v9rX0,5729
-sqlalchemy/dialects/mysql/mysqldb.py,sha256=5ME7B0WI9G8tw5482YBejDg38uVMXR2oUasNDOCsAqQ,9526
-sqlalchemy/dialects/mysql/provision.py,sha256=5LCeInPvyEbGuzxSs9rnnLYkMsFpW3IJ8lC-sjTfKnk,3575
-sqlalchemy/dialects/mysql/pymysql.py,sha256=osp0em1s3Cip5Vpcj-PeaH7btHEInorO-qs351muw3Q,4082
-sqlalchemy/dialects/mysql/pyodbc.py,sha256=ZiFNJQq2qiOTzTZLmNJQ938EnS1ItVsNDa3fvNEDqnI,4298
-sqlalchemy/dialects/mysql/reflection.py,sha256=eGV9taua0nZS_HsHyAy6zjcHEHFPXmFdux-bUmtOeWs,22834
-sqlalchemy/dialects/mysql/reserved_words.py,sha256=C9npWSuhsxoVCqETxCQ1zE_UEgy4gfiHw9zI5dPkjWI,9258
-sqlalchemy/dialects/mysql/types.py,sha256=w68OASMw04xkyAc0_GtXkuEhhVqlR6LTwaOch4KaAFQ,24343
-sqlalchemy/dialects/oracle/__init__.py,sha256=rp9qPRNQAk1Yq_Zhe7SsUH8EvFgNOAh8XOF17Lkxpyo,1493
-sqlalchemy/dialects/oracle/base.py,sha256=_JF4OwXmXjAsXj8wXq2m8M2vtMjoxdlOwg1hfcgn3bc,123096
-sqlalchemy/dialects/oracle/cx_oracle.py,sha256=ohENTgLxGUfobRH3K8KdeZgBRPG1rX3vY-ph9blj-2g,56612
-sqlalchemy/dialects/oracle/dictionary.py,sha256=J7tGVE0KyUPZKpPLOary3HdDq1DWd29arF5udLgv8_o,19519
-sqlalchemy/dialects/oracle/oracledb.py,sha256=veqto1AUIbSxRmpUQin0ysMV8Y6sWAkzXt7W8IIl118,33771
-sqlalchemy/dialects/oracle/provision.py,sha256=ga1gNQZlXZKk7DYuYegllUejJxZXRKDGa7dbi_S_poc,8313
-sqlalchemy/dialects/oracle/types.py,sha256=axN6Yidx9tGRIUAbDpBrhMWXE-C8jSllFpTghpGOOzU,9058
-sqlalchemy/dialects/postgresql/__init__.py,sha256=kD8W-SV5e2CesvWg2MQAtncXuZFwGPfR_UODvmRXE08,3892
-sqlalchemy/dialects/postgresql/_psycopg_common.py,sha256=szME-lCjVwqnW9-USA6e8ke8N_bN3IbqnIm_oZruvqc,5696
-sqlalchemy/dialects/postgresql/array.py,sha256=28kndSQwgvNWlO4z6MUh5WYAtNSgkgBa6qSEQCIflks,13856
-sqlalchemy/dialects/postgresql/asyncpg.py,sha256=ysIDXcGT3OG2lu0YdiIn-_pzfL0uDe-tmHs70fOWVVE,41283
-sqlalchemy/dialects/postgresql/base.py,sha256=otAswEHqeRhbN9_AGMxnwDo6r872ECkiJ5FMetXfS0k,179452
-sqlalchemy/dialects/postgresql/dml.py,sha256=2SmyMeYveAgm7OnT_CJvwad2nh8BP37yT6gFs8dBYN8,12126
-sqlalchemy/dialects/postgresql/ext.py,sha256=MtN4IU5sRYvoY-E8PTltJ1CuIGb-aCwY2pHMPJcTboA,16318
-sqlalchemy/dialects/postgresql/hstore.py,sha256=wR4gmvfQWPssHwYTXEsPJTb4LkBS6x4e4XXE6smtDH4,11934
-sqlalchemy/dialects/postgresql/json.py,sha256=9sHFGTRFyNbLsANrVYookw9NOJwIPTsEBRNIOUOzOGw,11612
-sqlalchemy/dialects/postgresql/named_types.py,sha256=TEWaBCjuHM2WJoQNrQErQ6f_bUkWypGJfW71wzVJXWc,17572
-sqlalchemy/dialects/postgresql/operators.py,sha256=ay3ckNsWtqDjxDseTdKMGGqYVzST6lmfhbbYHG_bxCw,2808
-sqlalchemy/dialects/postgresql/pg8000.py,sha256=RAykzZuO3Anr6AsyK2JYr7CPb2pru6WtkrX2phCyCGU,18638
-sqlalchemy/dialects/postgresql/pg_catalog.py,sha256=lgJMn7aDuJI2XeHddLkge5NFy6oB2-aDSn8A47QpwAU,9254
-sqlalchemy/dialects/postgresql/provision.py,sha256=7pg9-nOnaK5XBzqByXNPuvi3rxtnRa3dJxdSPVq4eeA,5770
-sqlalchemy/dialects/postgresql/psycopg.py,sha256=k7zXsJj35aOXCrhsbMxwTQX5JWegrqirFJ1Hgbq-GjQ,23326
-sqlalchemy/dialects/postgresql/psycopg2.py,sha256=1KXw9RzsQEAXJazCBywdP5CwLu-HsCSDAD_Khc_rPTM,32032
-sqlalchemy/dialects/postgresql/psycopg2cffi.py,sha256=nKilJfvO9mJwk5NRw5iZDekKY5vi379tvdUJ2vn5eyQ,1756
-sqlalchemy/dialects/postgresql/ranges.py,sha256=fnaj4YgCQGO-G_S4k5ea8bYMH7SzggKJdUX5qfaNp4Y,32978
-sqlalchemy/dialects/postgresql/types.py,sha256=sjb-m-h49lbLBFh0P30G8BWgf_aKNiNyVwWEktugwRw,7286
-sqlalchemy/dialects/sqlite/__init__.py,sha256=6Xcz3nPsl8lqCcZ4-VzPRmkMrkKgAp2buKsClZelU7c,1182
-sqlalchemy/dialects/sqlite/aiosqlite.py,sha256=FWS-Nn2jnpITQKGd4xOZCYEW-l1C_erQ3IdDJC855t8,12348
-sqlalchemy/dialects/sqlite/base.py,sha256=PvwPzukomHAkufUzSqgfJcbKC2ZJAkJbVnW2BQB2T58,98271
-sqlalchemy/dialects/sqlite/dml.py,sha256=4N8qh06RuMphLoQgWw7wv5nXIrka57jIFvK2x9xTZqg,9138
-sqlalchemy/dialects/sqlite/json.py,sha256=A62xPyLRZxl2hvgTMM92jd_7jlw9UE_4Y6Udqt-8g04,2777
-sqlalchemy/dialects/sqlite/provision.py,sha256=iLJyeQSy8pfr9lwEu4_d4O_CI4OavAtkNeRi3qqys1U,5632
-sqlalchemy/dialects/sqlite/pysqlcipher.py,sha256=di8rYryfL0KAn3pRGepmunHyIRGy-4Hhr-2q_ehPzss,5371
-sqlalchemy/dialects/sqlite/pysqlite.py,sha256=rg7F1S2UOhUu6Y1xNVaqF8VbA-FsRY_Y_XpGTpkKpGs,28087
-sqlalchemy/dialects/type_migration_guidelines.txt,sha256=-uHNdmYFGB7bzUNT6i8M5nb4j6j9YUKAtW4lcBZqsMg,8239
-sqlalchemy/engine/__init__.py,sha256=EF4haWCPu95WtWx1GzcHRJ_bBmtJMznno3I2TQ-ZIHE,2818
-sqlalchemy/engine/_py_processors.py,sha256=7QxgkVOd5h1Qd22qFh-pPZdM7RBRzNjj8lWAMWrilcI,3744
-sqlalchemy/engine/_py_row.py,sha256=yNdrZe36yw6mO7x0OEbG0dGojH7CQkNReIwn9LMUPUs,3787
-sqlalchemy/engine/_py_util.py,sha256=LdpbNRQIrJo3EkmiwNkM5bxGUf4uWuL5uS_u-zHadWc,2484
-sqlalchemy/engine/base.py,sha256=9kCWrDp3ECOlQ7BHK_efYAILo3-emcPSk4F8AFRgN7E,122901
-sqlalchemy/engine/characteristics.py,sha256=PepmGApo1sL01dS1qtSbmHplu9ZCdtuSegiGI7L7NZY,4765
-sqlalchemy/engine/create.py,sha256=4gFkqV7fgJbI1906DC4zDgFFX1-xJQ96GIHIrQuc-w4,33217
-sqlalchemy/engine/cursor.py,sha256=6KIZqlwWMUMv02w_el4uNYFMYcfc7eWbkAxW27UyDLE,76305
-sqlalchemy/engine/default.py,sha256=SHM6boxcDNk7MW_Eyd0zCb557Eqf8KTdX1iTUbS0DLw,84705
-sqlalchemy/engine/events.py,sha256=4_e6Ip32ar2Eb27R4ipamiKC-7Tpg4lVz3txabhT5Rc,37400
-sqlalchemy/engine/interfaces.py,sha256=fGmcrBt8yT78ty0R3e3XUvsPh7XYDU_b1JW3QhK_MwY,113029
-sqlalchemy/engine/mock.py,sha256=_aXG1xzj_TO5UWdz8IthPj1ZJ8IlhsKw6D9mmFN_frQ,4181
-sqlalchemy/engine/processors.py,sha256=XK32bULBkuVVRa703u4-SrTCDi_a18Dxq1M09QFBEPw,2379
-sqlalchemy/engine/reflection.py,sha256=_v9zCy3h28hN4KKIUTc5_7KJv7argSgi8A011b_iCdc,75383
-sqlalchemy/engine/result.py,sha256=rgny4qFLmpj80GSdFK35Dpgc3Qk2tc3eJPpahGWVR-M,77622
-sqlalchemy/engine/row.py,sha256=BPtAwsceiRxB9ANpDNM24uQ1M_Zs0xFkSXoKR_I8xyY,12031
-sqlalchemy/engine/strategies.py,sha256=-0rieXY-iXgV83OrJZr-wozFFQn3amKKHchQ6kL-r7A,442
-sqlalchemy/engine/url.py,sha256=gaEeSEJCD0nVEb8J02rIMASrd5L2wYdq5ZXJaj7szVI,31069
-sqlalchemy/engine/util.py,sha256=4OmXwFlmnq6_vBlfUBHnz5LrI_8bT3TwgynX4wcJfnw,5682
-sqlalchemy/event/__init__.py,sha256=ZjVxFGbt9neH5AC4GFiUN5IG2O4j6Z9v2LdmyagJi9w,997
-sqlalchemy/event/api.py,sha256=NetgcQfbURaZzoxus7_801YDG_LJ7PYqaC3T1lws114,8111
-sqlalchemy/event/attr.py,sha256=YhPXVBPj63Cfyn0nS6h8Ljq0SEbD3mtAZn9HYlzGbtw,20751
-sqlalchemy/event/base.py,sha256=OevVb82IrUoVgFRrjH4b5GquS5pjFHOgzWAxPwwTKMY,15127
-sqlalchemy/event/legacy.py,sha256=lGafKAOF6PY8Bz0AqhN9Q6n-lpXqFLwdv-0T6-UBpow,8227
-sqlalchemy/event/registry.py,sha256=MNEMyR8HZhzQFgxk4Jk_Em6nXTihmGXiSIwPdUnalPM,11144
-sqlalchemy/events.py,sha256=VBRvtckn9JS3tfUfi6UstqUrvQ15J2xamcDByFysIrI,525
-sqlalchemy/exc.py,sha256=AjFBCrOl_V4vQdGegn72Y951RSRMPL6T5qjxnFTGFbM,23978
-sqlalchemy/ext/__init__.py,sha256=BkTNuOg454MpCY9QA3FLK8td7KQhD1W74fOEXxnWibE,322
-sqlalchemy/ext/associationproxy.py,sha256=VhOFB1vB8hmDYQP90_VdpPI9IFzP3NENkG_eDKziVoI,66062
-sqlalchemy/ext/asyncio/__init__.py,sha256=kTIfpwsHWhqZ-VMOBZFBq66kt1XeF0hNuwOToEDe4_Y,1317
-sqlalchemy/ext/asyncio/base.py,sha256=2YQ-nKaHbAm--7q6vbxbznzdwT8oPwetwAarKyu2O8E,8930
-sqlalchemy/ext/asyncio/engine.py,sha256=fe_RZrO-5DiiEgMZ3g-Lti-fdaR7z_Q8gDfPUf-30EY,48198
-sqlalchemy/ext/asyncio/exc.py,sha256=npijuILDXH2p4Q5RzhHzutKwZ5CjtqTcP-U0h9TZUmk,639
-sqlalchemy/ext/asyncio/result.py,sha256=zhhXe13vMT7OfdfGXapgtn4crtiqqctRLb3ka4mmGXY,30477
-sqlalchemy/ext/asyncio/scoping.py,sha256=4f7MX3zUd-4rA8A5wd7j0_GlqCSUxdOPfYd7BBIxkJI,52587
-sqlalchemy/ext/asyncio/session.py,sha256=2wxu06UtJGyf-be2edMFkcK4eLMh8xuGmsAlGRj0YPM,63166
-sqlalchemy/ext/automap.py,sha256=n88mktqvExwjqfsDu3yLIA4wbOIWUpQ1S35Uw3X6ffQ,61675
-sqlalchemy/ext/baked.py,sha256=w3SeRoqnPkIhPL2nRAxfVhyir2ypsiW4kmtmUGKs8qo,17753
-sqlalchemy/ext/compiler.py,sha256=f7o4qhUUldpsx4F1sQoUvdVaT2BhiemqNBCF4r_uQUo,20889
-sqlalchemy/ext/declarative/__init__.py,sha256=SuVflXOGDxx2sB2QSTqNEvqS0fyhOkh3-sy2lRsSOLA,1818
-sqlalchemy/ext/declarative/extensions.py,sha256=yHUPcztU-5E1JrNyELDFWKchAnaYK6Y9-dLcqyc1nUI,19531
-sqlalchemy/ext/horizontal_shard.py,sha256=vouIehpQAuwT0HXyWyynTL3m_gcBuLcB-X8lDB0uQ8U,16691
-sqlalchemy/ext/hybrid.py,sha256=DkvNGtiQYzlEBvs1rYEDXhM8vJEXXh_6DMigsHH9w4k,52531
-sqlalchemy/ext/indexable.py,sha256=_dTOgCS96jURcQd9L-hnUMIJDe9KUMyd9gfH57vs078,11065
-sqlalchemy/ext/instrumentation.py,sha256=iCp89rvfK7buW0jJyzKTBDKyMsd06oTRJDItOk4OVSw,15707
-sqlalchemy/ext/mutable.py,sha256=7Zyh2kQq2gm3J_JwsddinIXk7qUuKWbPzRZOmTultEk,37560
-sqlalchemy/ext/mypy/__init__.py,sha256=yVNtoBDNeTl1sqRoA_fSY3o1g6M8NxqUVvAHPRLmFTw,241
-sqlalchemy/ext/mypy/apply.py,sha256=v_Svc1WiBz9yBXqBVBKoCuPGN286TfVmuuCVZPlbyzo,10591
-sqlalchemy/ext/mypy/decl_class.py,sha256=Nuca4ofHkASAkdqEQlULYB7iLm_KID7Mp384seDhVGg,17384
-sqlalchemy/ext/mypy/infer.py,sha256=29vgn22Hi8E8oIZL6UJCBl6oipiPSAQjxccCEkVb410,19367
-sqlalchemy/ext/mypy/names.py,sha256=hn889DD1nlF0f3drsKi5KSGTG-JefJ2UJrrIQ4L8QWA,10479
-sqlalchemy/ext/mypy/plugin.py,sha256=9YHBp0Bwo92DbDZIUWwIr0hwXPcE4XvHs0-xshvSwUw,9750
-sqlalchemy/ext/mypy/util.py,sha256=CuW2fJ-g9YtkjcypzmrPRaFc-rAvQTzW5A2-w5VTANg,9960
-sqlalchemy/ext/orderinglist.py,sha256=MROa19cm4RZkWXuUuqc1029r7g4HrAJRc17fTHeThvI,14431
-sqlalchemy/ext/serializer.py,sha256=_z95wZMTn3G3sCGN52gwzD4CuKjrhGMr5Eu8g9MxQNg,6169
-sqlalchemy/future/__init__.py,sha256=R1h8VBwMiIUdP3QHv_tFNby557425FJOAGhUoXGvCmc,512
-sqlalchemy/future/engine.py,sha256=2nJFBQAXAE8pqe1cs-D3JjC6wUX2ya2h2e_tniuaBq0,495
-sqlalchemy/inspection.py,sha256=qKEKG37N1OjxpQeVzob1q9VwWjBbjI1x0movJG7fYJ4,5063
-sqlalchemy/log.py,sha256=e_ztNUfZM08FmTWeXN9-doD5YKW44nXxgKCUxxNs6Ow,8607
-sqlalchemy/orm/__init__.py,sha256=BICvTXpLaTNe2AiUaxnZHWzjL5miT9fd_IU-ip3OFNk,8463
-sqlalchemy/orm/_orm_constructors.py,sha256=NiAagQ1060QYS9n5y_gzPvHQQz44EN1dVtamGVtde6E,103626
-sqlalchemy/orm/_typing.py,sha256=vaYRl4_K3n-sjc9u0Rb4eWWpBOoOi92--OHqaGogRvA,4973
-sqlalchemy/orm/attributes.py,sha256=e_U0A4TGWAzL3yXVvk9YVhIRjKM4RTsIE2PNRLn8LbU,92534
-sqlalchemy/orm/base.py,sha256=oCgscNoRrqHwYvc1Iz8ZFhoVXhalu45g9z0m_7_ldaE,27502
-sqlalchemy/orm/bulk_persistence.py,sha256=Ciea9MhJ6ZbAi-uGy5-Kj6lodO9bfRqPq8GSf2qFshE,72663
-sqlalchemy/orm/clsregistry.py,sha256=syn6bB-Ylx-juh5GDCmNrPZ58C-z6sdwRkbZFeKysQU,17974
-sqlalchemy/orm/collections.py,sha256=XxZC8d9UX9E2R-WlNH198OPWRPmpLuYt0Y26LrdbuHc,52252
-sqlalchemy/orm/context.py,sha256=eyh7xTo3SyxIHl8_NBUqJ_GpJ0kZtmnTt32Z67cfqgs,112973
-sqlalchemy/orm/decl_api.py,sha256=SJ25fQjjKyWZDQbq5S69eiybpOzns0LkRziP10iW5-E,64969
-sqlalchemy/orm/decl_base.py,sha256=ZlZmyNVOsCPA_pThMeXuWmAhlJwlvTxdGXhnARsKxhk,83288
-sqlalchemy/orm/dependency.py,sha256=4NMhoogevOiX1Wm5B1_yY2u9MHYlIjJNNoEVRE0yLwA,47631
-sqlalchemy/orm/descriptor_props.py,sha256=LgfdiO_U5uznq5ImenfbWGV5T47bH4b_ztbzB4B7FsU,37231
-sqlalchemy/orm/dynamic.py,sha256=Z4GpcVL8rM8gi0bytQOZXw-_kKi-sExbRWGjU30dK3g,9816
-sqlalchemy/orm/evaluator.py,sha256=PKrUW1zEOvmv1XEgc_hBdYqNcyk4zjWr_rJhCEQBFIc,12353
-sqlalchemy/orm/events.py,sha256=OZtTCpI-DVaE6CY16e42GUVpci1U1GjdNO76xU-Tj5Y,127781
-sqlalchemy/orm/exc.py,sha256=zJgAIofYsWKjktqO5MFxze95GlJASziEOJJx-P5_wOU,7413
-sqlalchemy/orm/identity.py,sha256=5NFtF9ZPZWAOmtOqCPyVX2-_pQq9A5XeN2ns3Wirpv8,9249
-sqlalchemy/orm/instrumentation.py,sha256=WhElvvOWOn3Fuc-Asc5HmcKDX6EzFtBleLJKPZEc5A0,24321
-sqlalchemy/orm/interfaces.py,sha256=W6ADDLOixmm4tnSnUP_I9HFLj9MCO2bODk_WTNjkZGA,48797
-sqlalchemy/orm/loading.py,sha256=6Rd1hWtBPm7SfCUpjPQrcoUg_DSCcfhO8Qhz7SScjRE,58277
-sqlalchemy/orm/mapped_collection.py,sha256=FAqaTlOUCYqdws2KR_fW0T8mMWIrLuAxJGU5f4W1aGs,19682
-sqlalchemy/orm/mapper.py,sha256=-gkJKHeAJmIFT153WFIIySduyyLGbT5plCgSfnsa0I0,171668
-sqlalchemy/orm/path_registry.py,sha256=-aAEhGkDf_2ZUXmHQICQNOa4Z5xhTlhlYLag7eoVpxE,25920
-sqlalchemy/orm/persistence.py,sha256=Uz45Cwxi7FnNiSk2crbh3TzV7b9kb85vmcvOwy5NVmw,61701
-sqlalchemy/orm/properties.py,sha256=vbx_YiSjj3tI94-G-_ghbyWYcIIJQQeGG1P-0RC8Jv4,29065
-sqlalchemy/orm/query.py,sha256=GI_go9ErXYK1BteCmIh5E9iv-jfMJkRBVIlw0XmnYyk,118540
-sqlalchemy/orm/relationships.py,sha256=C40n_-oliMgJJ0FHfwsi1-dm963CrYeKJ5HEYjLdg_o,128899
-sqlalchemy/orm/scoping.py,sha256=-SNRAewfMJ4x4Um8X-yv0k1Thz8E1_kCBmbmG1l1auo,78617
-sqlalchemy/orm/session.py,sha256=1fzksIcb9DtKcwqkS1KkZngkrEYGUHmoNW_o6l8IXQ4,196114
-sqlalchemy/orm/state.py,sha256=1vtlz674sGFmwZ8Ih9TdrslA-0nhU2G52WgV-FoG2j0,37670
-sqlalchemy/orm/state_changes.py,sha256=XJLYYhTZu7nA6uD7xupbLZ9XSzqLYwrDJgW0ZAWvVGE,6815
-sqlalchemy/orm/strategies.py,sha256=qziXv4z2bJeF2qFSj6wogc9BLlxuOnT8nOcEvocVf88,119866
-sqlalchemy/orm/strategy_options.py,sha256=wMYd4E_nRb5ei8Fr3jWeSewNY2k1-AfqtYRGOLiHOFA,85043
-sqlalchemy/orm/sync.py,sha256=RdoxnhvgNjn3Lhtoq4QjvXpj8qfOz__wyibh0FMON0A,5779
-sqlalchemy/orm/unitofwork.py,sha256=hkSIcVonoSt0WWHk019bCDEw0g2o2fg4m4yqoTGyAoo,27033
-sqlalchemy/orm/util.py,sha256=rtClCjtg0eSSC8k-L30W0v6BauJaJuh9Nf-MSqofWuQ,80831
-sqlalchemy/orm/writeonly.py,sha256=R-MVxYDw0ZQ795H21yBtgGSZXWUzSovcb_SO1mv5hoI,22305
-sqlalchemy/pool/__init__.py,sha256=niqzCv2uOZT07DOiV2inlmjrW3lZyqDXGCjnOl1IqJ4,1804
-sqlalchemy/pool/base.py,sha256=mT-PHTlVUGcYRVsMB9LQwNgndjhOTOorWX5-hNRi2FM,52236
-sqlalchemy/pool/events.py,sha256=wdFfvat0fSrVF84Zzsz5E3HnVY0bhL7MPsGME-b2qa8,13149
-sqlalchemy/pool/impl.py,sha256=MLSh83SGNNtZZgZvA-5tvTIT8Dz7U95Bgt8HO_oR1Ps,18944
-sqlalchemy/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
-sqlalchemy/schema.py,sha256=yt4dcuMAKMleUHVidsAVAsm-JPpASFZXP2xM3pmzYHY,3194
-sqlalchemy/sql/__init__.py,sha256=Y-bZ25Zf-bxqsF2zUkpRGTjFuozNNVQHxUJV3Qmaq2M,5820
-sqlalchemy/sql/_dml_constructors.py,sha256=JF_XucNTfAk6Vz9fYiPWOgpIGtUkDj6VPILysLcrVhk,3795
-sqlalchemy/sql/_elements_constructors.py,sha256=eoQhkoRH0qox171ZSODyxxhj_HZEhO64rSowaN-I-v4,62630
-sqlalchemy/sql/_orm_types.py,sha256=0zeMit-V4rYZe-bB9X3xugnjFnPXH0gmeqkJou9Fows,625
-sqlalchemy/sql/_py_util.py,sha256=4KFXNvBq3hhfrr-A1J1uBml3b3CGguIf1dat9gsEHqE,2173
-sqlalchemy/sql/_selectable_constructors.py,sha256=fwVBsDHHWhngodBG205nvhM-Tb3uR1srbCnN3mPgrjA,18785
-sqlalchemy/sql/_typing.py,sha256=zYKlxXnUW_KIkGuBmBnzj-vFG1QON8_F9JN1dl9KSiM,12771
-sqlalchemy/sql/annotation.py,sha256=qHUEwbdmMD3Ybr0ez-Dyiw9l9UB_RUMHWAUIeO_r3gE,18245
-sqlalchemy/sql/base.py,sha256=kfmVNRimU5z6X6OKqMLMs1bDCFQ47BeyF_MZc23nkjY,73848
-sqlalchemy/sql/cache_key.py,sha256=ET2OIQ6jZK2FSxsdnCvhLCrNJ2Fp3zipQ-gvINgAjhQ,33668
-sqlalchemy/sql/coercions.py,sha256=lRciS5agnpVvx_vHYxJV-aN6QOVb_O4yCnMZ0s07GUE,40750
-sqlalchemy/sql/compiler.py,sha256=eT_zrKvApimVfycvcTdubQK8-QAzGHm5xWKdhOgnWUY,274965
-sqlalchemy/sql/crud.py,sha256=vFegNw5557ayS4kv761zh0bx0yikEKh1ovMrhErHelg,56514
-sqlalchemy/sql/ddl.py,sha256=rfb7gDvLmn_ktgH2xiXLRTczqnMOED1eakXuGuRPklg,45641
-sqlalchemy/sql/default_comparator.py,sha256=uXLr8B-X6KbybwTjLjZ2hN-WZAvqoMhZ-DDHJX7rAUw,16707
-sqlalchemy/sql/dml.py,sha256=oTW8PB-55qf6crAkbxh2JD-TvkT3MO1zqkKDrt5-2c8,65611
-sqlalchemy/sql/elements.py,sha256=RYq5N-IEPnhcDKtokeaCDIGZiUex8oDgwRLCDqjkk_g,176482
-sqlalchemy/sql/events.py,sha256=iWjc_nm1vClDBLg4ZhDnY75CkBdnlDPSPe0MGBSmbiM,18312
-sqlalchemy/sql/expression.py,sha256=rw5tAm8vbd5Vm4MofTZ0ZcXsphz4z9xO_exy-gem6TM,7586
-sqlalchemy/sql/functions.py,sha256=tbBxIeAqLV3kc1YDxyt68mxw0fFy6e93ctRUZSuuf3I,63858
-sqlalchemy/sql/lambdas.py,sha256=h9sPCETBgAanLtVHQsRPHeY-hTEjM5nscq3m4bDstwM,49196
-sqlalchemy/sql/naming.py,sha256=BU0ZdSzXXKHTPhoaKMWJ3gPMoeZSJJe9-3YDYflmjJw,6858
-sqlalchemy/sql/operators.py,sha256=h5bgu31gukGdsYsN_0-1C7IGAdSCFpBxuRjOUnu1Two,76792
-sqlalchemy/sql/roles.py,sha256=drAeWbevjgFAKNcMrH_EuJ-9sSvcq4aeXwAqMXXZGYw,7662
-sqlalchemy/sql/schema.py,sha256=WKKwxkC9oNRHN-B4s35NkWcr5dvavccKf-_1t35Do8A,229896
-sqlalchemy/sql/selectable.py,sha256=5Za7eh4USrgVwJgQGVX1bb2w1qXcy-hGzGpWNPbhf68,237610
-sqlalchemy/sql/sqltypes.py,sha256=yXHvZXfZJmaRvMoX4_jXqazAev33pk0Ltwl5c-D5Ha4,128609
-sqlalchemy/sql/traversals.py,sha256=7GALHt5mFceUv2SMUikIdAb9SUcSbACqhwoei5rPkxc,33664
-sqlalchemy/sql/type_api.py,sha256=wdi3nmOBRdhG6L1z21V_PwQGB8CIRouMdNKoIzJA4Zo,84440
-sqlalchemy/sql/util.py,sha256=G-2ZI6rZ7XxVu5YXaVvLrApeAk5VwSG4C--lqtglgGE,48086
-sqlalchemy/sql/visitors.py,sha256=URpw-GxxUkwjEDbD2xXJGyFJavG5lN6ISoY34JlYRS8,36319
-sqlalchemy/testing/__init__.py,sha256=GgUEqxUNCxg-92_GgBDnljUHsdCxaGPMG1TWy5tjwgk,3160
-sqlalchemy/testing/assertions.py,sha256=RFTkxGq-kDvn3JSUuT_6bU1y0vtoI6pE6ryZgV2YEx4,31439
-sqlalchemy/testing/assertsql.py,sha256=cmhtZrgPBjrqIfzFz3VBWxVNvxWoRllvmoWcUCoqsio,16817
-sqlalchemy/testing/asyncio.py,sha256=QsMzDWARFRrpLoWhuYqzYQPTUZ80fymlKrqOoDkmCmQ,3830
-sqlalchemy/testing/config.py,sha256=HySdB5_FgCW1iHAJVxYo-4wq5gUAEi0N8E93IC6M86Q,12058
-sqlalchemy/testing/engines.py,sha256=c1gFXfpo5S1dvNjGIL03mbW2eVYtUD_9M_ZEfQO2ArM,13414
-sqlalchemy/testing/entities.py,sha256=KdgTVPSALhi9KkAXj2giOYl62ld-1yZziIDBSV8E3vw,3354
-sqlalchemy/testing/exclusions.py,sha256=jzVrBXqyQlyMgvfChMjJOd0ZtReKgkJ4Ik-0mkWe6KM,12460
-sqlalchemy/testing/fixtures/__init__.py,sha256=e5YtfSlkKDRuyIZhEKBCycMX5BOO4MZ-0d97l1JDhJE,1198
-sqlalchemy/testing/fixtures/base.py,sha256=y5iEEdUZIft06fvAOXwKU73ciIFTO5AVgDDGzYD9nOY,12256
-sqlalchemy/testing/fixtures/mypy.py,sha256=9fuJ90F9LBki26dVEVOEtRVXG2koaK803k4nukTnA8o,11973
-sqlalchemy/testing/fixtures/orm.py,sha256=3JJoYdI2tj5-LL7AN8bVa79NV3Guo4d9p6IgheHkWGc,6095
-sqlalchemy/testing/fixtures/sql.py,sha256=ht-OD6fMZ0inxucRzRZG4kEMNicqY8oJdlKbZzHhAJc,15900
-sqlalchemy/testing/pickleable.py,sha256=G3L0xL9OtbX7wThfreRjWd0GW7q0kUKcTUuCN5ETGno,2833
-sqlalchemy/testing/plugin/__init__.py,sha256=vRfF7M763cGm9tLQDWK6TyBNHc80J1nX2fmGGxN14wY,247
-sqlalchemy/testing/plugin/bootstrap.py,sha256=VYnVSMb-u30hGY6xGn6iG-LqiF0CubT90AJPFY_6UiY,1685
-sqlalchemy/testing/plugin/plugin_base.py,sha256=TBWdg2XgXB6QgUUFdKLv1O9-SXMitjHLm2rNNIzXZhQ,21578
-sqlalchemy/testing/plugin/pytestplugin.py,sha256=0rRCp7RlnhJBg3gJEq0t0kJ-BCTQ34bqBE_lEQk5U3U,27656
-sqlalchemy/testing/profiling.py,sha256=SWhWiZImJvDsNn0rQyNki70xdNxZL53ZI98ihxiykbQ,10148
-sqlalchemy/testing/provision.py,sha256=6r2FTnm-t7u8MMbWo7eMhAH3qkL0w0WlmE29MUSEIu4,14702
-sqlalchemy/testing/requirements.py,sha256=MVuTKtZjeTZaYlrAU8XFIB1bhJA_AedqL_q7NwVEGiw,52956
-sqlalchemy/testing/schema.py,sha256=IImFumAdpzOyoKAs0WnaGakq8D3sSU4snD9W4LVOV3s,6513
-sqlalchemy/testing/suite/__init__.py,sha256=S8TLwTiif8xX67qlZUo5I9fl9UjZAFGSzvlptp2WoWc,722
-sqlalchemy/testing/suite/test_cte.py,sha256=d3OWDBNhnAwlyAz_QhFk-vKSWaAI3mADVnqdtTWOuwI,6451
-sqlalchemy/testing/suite/test_ddl.py,sha256=MItp-votCzvahlRqHRagte2Omyq9XUOFdFsgzCb6_-g,12031
-sqlalchemy/testing/suite/test_deprecations.py,sha256=7C6IbxRmq7wg_DLq56f1V5RCS9iVrAv3epJZQTB-dOo,5337
-sqlalchemy/testing/suite/test_dialect.py,sha256=eGJFZCwKmLrIl66ZlkLLZf5Fq6bzWI174gQsJt2bY2c,22923
-sqlalchemy/testing/suite/test_insert.py,sha256=pR0VWMQ9JJPbnANE6634PzR0VFmWMF8im6OTahc4vsQ,18824
-sqlalchemy/testing/suite/test_reflection.py,sha256=EJvTjRDimw9k90zlI5VCkmCzf7Tv5VF9y4O3D8SZMFU,109648
-sqlalchemy/testing/suite/test_results.py,sha256=9FFBNLeXcNRIC9FHfEjFKwfV6w2Bb58ulml_M8Zdokg,16914
-sqlalchemy/testing/suite/test_rowcount.py,sha256=UVyHHQsU0TxkzV_dqCOKR1aROvIq7frKYMVjwUqLWfE,7900
-sqlalchemy/testing/suite/test_select.py,sha256=S81w-Dox6W29Tjmi6LIBJ4HuB5E8dDAzmePDm0PKTYo,61732
-sqlalchemy/testing/suite/test_sequence.py,sha256=DMqyJkL1o4GClrNjzoy7GDn_jPNPTZNvk9t5e-MVXeo,9923
-sqlalchemy/testing/suite/test_types.py,sha256=gPA6t-90Icnpj2ZzITwbqka1DB-rNOoh6_xS9dC-4HU,67805
-sqlalchemy/testing/suite/test_unicode_ddl.py,sha256=0zVc2e3zbCQag_xL4b0i7F062HblHwV46JHLMweYtcE,6141
-sqlalchemy/testing/suite/test_update_delete.py,sha256=_OxH0wggHUqPImalGEPI48RiRx6mO985Om1PtRYOCzA,3994
-sqlalchemy/testing/util.py,sha256=KsUInolFBXUPIXVZKAdb_8rQrW8yW8OCtiA3GXuYRvA,14571
-sqlalchemy/testing/warnings.py,sha256=sj4vfTtjodcfoX6FPH_Zykb4fomjmgqIYj81QPpSwH8,1546
-sqlalchemy/types.py,sha256=m3I9h6xoyT7cjeUx5XCzmaE-GHT2sJVwECiuSJl75Ss,3168
-sqlalchemy/util/__init__.py,sha256=tYWkZV6PYVfEW32zt48FCLH12VyV_kaNUa3KBAOYpSM,8312
-sqlalchemy/util/_collections.py,sha256=RbP4UixqNtRBUrl_QqYDiadVmELSVxxXm2drhvQaIKo,20078
-sqlalchemy/util/_concurrency_py3k.py,sha256=UtPDkb67OOVWYvBqYaQgENg0k_jOA2mQOE04XmrbYq0,9170
-sqlalchemy/util/_has_cy.py,sha256=3oh7s5iQtW9qcI8zYunCfGAKG6fzo2DIpzP5p1BnE8Q,1247
-sqlalchemy/util/_py_collections.py,sha256=irOg3nkzxmtdYfIS46un2cp0JqSiACI7WGQBg-BaEXU,16714
-sqlalchemy/util/compat.py,sha256=TdDfvL21VnBEdSUnjcx-F8XhmVFg9Mvyr67a4omWZAM,8760
-sqlalchemy/util/concurrency.py,sha256=eQVS3YDH3GwB3Uw5pbzmqEBSYTK90EbnE5mQ05fHERg,3304
-sqlalchemy/util/deprecations.py,sha256=L7D4GqeIozpjO8iVybf7jL9dDlgfTbAaQH4TQAX74qE,12012
-sqlalchemy/util/langhelpers.py,sha256=G67avnsStFbslILlbCHmsyAMnShS7RYftFr9a8uFDL8,65140
-sqlalchemy/util/preloaded.py,sha256=RMarsuhtMW8ZuvqLSuR0kwbp45VRlzKpJMLUe7p__qY,5904
-sqlalchemy/util/queue.py,sha256=w1ufhuiC7lzyiZDhciRtRz1uyxU72jRI7SWhhL-p600,10185
-sqlalchemy/util/tool_support.py,sha256=e7lWu6o1QlKq4e6c9PyDsuyFyiWe79vO72UQ_YX2pUA,6135
-sqlalchemy/util/topological.py,sha256=HcJgdCeU0XFIskgIBnTaHXfRXaulaEJRYRwKv4yPNek,3458
-sqlalchemy/util/typing.py,sha256=C4jF7QTNo0w0bjvcIqSSTOvoy8FttuZtyTzjiyoIzQQ,20920
diff --git a/libs/SQLAlchemy-2.0.37.dist-info/WHEEL b/libs/SQLAlchemy-2.0.37.dist-info/WHEEL
deleted file mode 100644
index 42e750dfb3..0000000000
--- a/libs/SQLAlchemy-2.0.37.dist-info/WHEEL
+++ /dev/null
@@ -1,5 +0,0 @@
-Wheel-Version: 1.0
-Generator: setuptools (75.3.0)
-Root-Is-Purelib: false
-Tag: cp38-cp38-macosx_12_0_x86_64
-
diff --git a/libs/Unidecode-1.3.8.dist-info/METADATA b/libs/Unidecode-1.3.8.dist-info/METADATA
deleted file mode 100644
index 5c7086b9e8..0000000000
--- a/libs/Unidecode-1.3.8.dist-info/METADATA
+++ /dev/null
@@ -1,310 +0,0 @@
-Metadata-Version: 2.1
-Name: Unidecode
-Version: 1.3.8
-Summary: ASCII transliterations of Unicode text
-Author: Tomaz Solc
-Author-email: tomaz.solc@tablix.org
-License: GPL
-Classifier: License :: OSI Approved :: GNU General Public License v2 or later (GPLv2+)
-Classifier: Programming Language :: Python
-Classifier: Programming Language :: Python :: 3
-Classifier: Programming Language :: Python :: 3.5
-Classifier: Programming Language :: Python :: 3.6
-Classifier: Programming Language :: Python :: 3.7
-Classifier: Programming Language :: Python :: 3.8
-Classifier: Programming Language :: Python :: 3.9
-Classifier: Programming Language :: Python :: Implementation :: CPython
-Classifier: Programming Language :: Python :: Implementation :: PyPy
-Classifier: Topic :: Text Processing
-Classifier: Topic :: Text Processing :: Filters
-Requires-Python: >=3.5
-License-File: LICENSE
-
-Unidecode, lossy ASCII transliterations of Unicode text
-=======================================================
-
-It often happens that you have text data in Unicode, but you need to
-represent it in ASCII. For example when integrating with legacy code that
-doesn't support Unicode, or for ease of entry of non-Roman names on a US
-keyboard, or when constructing ASCII machine identifiers from human-readable
-Unicode strings that should still be somewhat intelligible. A popular example
-of this is when making an URL slug from an article title.
-
-**Unidecode is not a replacement for fully supporting Unicode for strings in
-your program. There are a number of caveats that come with its use,
-especially when its output is directly visible to users. Please read the rest
-of this README before using Unidecode in your project.**
-
-In most of examples listed above you could represent Unicode characters as
-``???`` or ``\\15BA\\15A0\\1610``, to mention two extreme cases. But that's
-nearly useless to someone who actually wants to read what the text says.
-
-What Unidecode provides is a middle road: the function ``unidecode()`` takes
-Unicode data and tries to represent it in ASCII characters (i.e., the
-universally displayable characters between 0x00 and 0x7F), where the
-compromises taken when mapping between two character sets are chosen to be
-near what a human with a US keyboard would choose.
-
-The quality of resulting ASCII representation varies. For languages of
-western origin it should be between perfect and good. On the other hand
-transliteration (i.e., conveying, in Roman letters, the pronunciation
-expressed by the text in some other writing system) of languages like
-Chinese, Japanese or Korean is a very complex issue and this library does
-not even attempt to address it. It draws the line at context-free
-character-by-character mapping. So a good rule of thumb is that the further
-the script you are transliterating is from Latin alphabet, the worse the
-transliteration will be.
-
-Generally Unidecode produces better results than simply stripping accents from
-characters (which can be done in Python with built-in functions). It is based
-on hand-tuned character mappings that for example also contain ASCII
-approximations for symbols and non-Latin alphabets.
-
-**Note that some people might find certain transliterations offending.** Most
-common examples include characters that are used in multiple languages. A user
-expects a character to be transliterated in their language but Unidecode uses a
-transliteration for a different language. It's best to not use Unidecode for
-strings that are directly visible to users of your application. See also the
-*Frequently Asked Questions* section for more info on common problems.
-
-This is a Python port of ``Text::Unidecode`` Perl module by Sean M. Burke
-.
-
-
-Module content
---------------
-
-This library contains a function that takes a string object, possibly
-containing non-ASCII characters, and returns a string that can be safely
-encoded to ASCII::
-
- >>> from unidecode import unidecode
- >>> unidecode('koลพuลกฤek')
- 'kozuscek'
- >>> unidecode('30 \U0001d5c4\U0001d5c6/\U0001d5c1')
- '30 km/h'
- >>> unidecode('\u5317\u4EB0')
- 'Bei Jing '
-
-You can also specify an *errors* argument to ``unidecode()`` that determines
-what Unidecode does with characters that are not present in its transliteration
-tables. The default is ``'ignore'`` meaning that Unidecode will ignore those
-characters (replace them with an empty string). ``'strict'`` will raise a
-``UnidecodeError``. The exception object will contain an *index* attribute that
-can be used to find the offending character. ``'replace'`` will replace them
-with ``'?'`` (or another string, specified in the *replace_str* argument).
-``'preserve'`` will keep the original, non-ASCII character in the string. Note
-that if ``'preserve'`` is used the string returned by ``unidecode()`` will not
-be ASCII-encodable!::
-
- >>> unidecode('\ue000') # unidecode does not have replacements for Private Use Area characters
- ''
- >>> unidecode('\ue000', errors='strict')
- Traceback (most recent call last):
- ...
- unidecode.UnidecodeError: no replacement found for character '\ue000' in position 0
-
-A utility is also included that allows you to transliterate text from the
-command line in several ways. Reading from standard input::
-
- $ echo hello | unidecode
- hello
-
-from a command line argument::
-
- $ unidecode -c hello
- hello
-
-or from a file::
-
- $ unidecode hello.txt
- hello
-
-The default encoding used by the utility depends on your system locale. You can
-specify another encoding with the ``-e`` argument. See ``unidecode --help`` for
-a full list of available options.
-
-Requirements
-------------
-
-Nothing except Python itself. Unidecode supports Python 3.5 or later.
-
-You need a Python build with "wide" Unicode characters (also called "UCS-4
-build") in order for Unidecode to work correctly with characters outside of
-Basic Multilingual Plane (BMP). Common characters outside BMP are bold, italic,
-script, etc. variants of the Latin alphabet intended for mathematical notation.
-Surrogate pair encoding of "narrow" builds is not supported in Unidecode.
-
-If your Python build supports "wide" Unicode the following expression will
-return True::
-
- >>> import sys
- >>> sys.maxunicode > 0xffff
- True
-
-See `PEP 261 `_ for details
-regarding support for "wide" Unicode characters in Python.
-
-
-Installation
-------------
-
-To install the latest version of Unidecode from the Python package index, use
-these commands::
-
- $ pip install unidecode
-
-To install Unidecode from the source distribution and run unit tests, use::
-
- $ python setup.py install
- $ python setup.py test
-
-Frequently asked questions
---------------------------
-
-German umlauts are transliterated incorrectly
- Latin letters "a", "o" and "u" with diaeresis are transliterated by
- Unidecode as "a", "o", "u", *not* according to German rules "ae", "oe",
- "ue". This is intentional and will not be changed. Rationale is that these
- letters are used in languages other than German (for example, Finnish and
- Turkish). German text transliterated without the extra "e" is much more
- readable than other languages transliterated using German rules. A
- workaround is to do your own replacements of these characters before
- passing the string to ``unidecode()``.
-
-Japanese Kanji is transliterated as Chinese
- Same as with Latin letters with accents discussed in the answer above, the
- Unicode standard encodes letters, not letters in a certain language or
- their meaning. With Japanese and Chinese this is even more evident because
- the same letter can have very different transliterations depending on the
- language it is used in. Since Unidecode does not do language-specific
- transliteration (see next question), it must decide on one. For certain
- characters that are used in both Japanese and Chinese the decision was to
- use Chinese transliterations. If you intend to transliterate Japanese,
- Chinese or Korean text please consider using other libraries which do
- language-specific transliteration, such as `Unihandecode
- `_.
-
-Unidecode should support localization (e.g. a language or country parameter, inspecting system locale, etc.)
- Language-specific transliteration is a complicated problem and beyond the
- scope of this library. Changes related to this will not be accepted. Please
- consider using other libraries which do provide this capability, such as
- `Unihandecode `_.
-
-Unidecode should automatically detect the language of the text being transliterated
- Language detection is a completely separate problem and beyond the scope of
- this library.
-
-Unidecode should use a permissive license such as MIT or the BSD license.
- The maintainer of Unidecode believes that providing access to source code
- on redistribution is a fair and reasonable request when basing products on
- voluntary work of many contributors. If the license is not suitable for
- you, please consider using other libraries, such as `text-unidecode
- `_.
-
-Unidecode produces completely wrong results (e.g. "u" with diaeresis transliterating as "A 1/4 ")
- The strings you are passing to Unidecode have been wrongly decoded
- somewhere in your program. For example, you might be decoding utf-8 encoded
- strings as latin1. With a misconfigured terminal, locale and/or a text
- editor this might not be immediately apparent. Inspect your strings with
- ``repr()`` and consult the
- `Unicode HOWTO `_.
-
-Why does Unidecode not replace \\u and \\U backslash escapes in my strings?
- Unidecode knows nothing about escape sequences. Interpreting these sequences
- and replacing them with actual Unicode characters in string literals is the
- task of the Python interpreter. If you are asking this question you are
- very likely misunderstanding the purpose of this library. Consult the
- `Unicode HOWTO `_ and possibly
- the ``unicode_escape`` encoding in the standard library.
-
-I've upgraded Unidecode and now some URLs on my website return 404 Not Found.
- This is an issue with the software that is running your website, not
- Unidecode. Occasionally, new versions of Unidecode library are released
- which contain improvements to the transliteration tables. This means that
- you cannot rely that ``unidecode()`` output will not change across
- different versions of Unidecode library. If you use ``unidecode()`` to
- generate URLs for your website, either generate the URL slug once and store
- it in the database or lock your dependency of Unidecode to one specific
- version.
-
-Some of the issues in this section are discussed in more detail in `this blog
-post `_.
-
-
-Performance notes
------------------
-
-By default, ``unidecode()`` optimizes for the use case where most of the strings
-passed to it are already ASCII-only and no transliteration is necessary (this
-default might change in future versions).
-
-For performance critical applications, two additional functions are exposed:
-
-``unidecode_expect_ascii()`` is optimized for ASCII-only inputs (approximately
-5 times faster than ``unidecode_expect_nonascii()`` on 10 character strings,
-more on longer strings), but slightly slower for non-ASCII inputs.
-
-``unidecode_expect_nonascii()`` takes approximately the same amount of time on
-ASCII and non-ASCII inputs, but is slightly faster for non-ASCII inputs than
-``unidecode_expect_ascii()``.
-
-Apart from differences in run time, both functions produce identical results.
-For most users of Unidecode, the difference in performance should be
-negligible.
-
-
-Source
-------
-
-You can get the latest development version of Unidecode with::
-
- $ git clone https://www.tablix.org/~avian/git/unidecode.git
-
-There is also an official mirror of this repository on GitHub at
-https://github.com/avian2/unidecode
-
-
-Contact
--------
-
-Please make sure to read the `Frequently asked questions`_ section above before
-contacting the maintainer.
-
-Bug reports, patches and suggestions for Unidecode can be sent to
-tomaz.solc@tablix.org.
-
-Alternatively, you can also open a ticket or pull request at
-https://github.com/avian2/unidecode
-
-
-Copyright
----------
-
-Original character transliteration tables:
-
-Copyright 2001, Sean M. Burke , all rights reserved.
-
-Python code and later additions:
-
-Copyright 2024, Tomaลพ ล olc
-
-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 2 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, write to the Free Software Foundation, Inc., 51
-Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. The programs and
-documentation in this dist are distributed in the hope that they will be
-useful, but without any warranty; without even the implied warranty of
-merchantability or fitness for a particular purpose.
-
-..
- vim: set filetype=rst:
diff --git a/libs/Unidecode-1.3.8.dist-info/RECORD b/libs/Unidecode-1.3.8.dist-info/RECORD
deleted file mode 100644
index 92b5732cab..0000000000
--- a/libs/Unidecode-1.3.8.dist-info/RECORD
+++ /dev/null
@@ -1,203 +0,0 @@
-../../bin/unidecode,sha256=NbqWJOWfXecMdJfbAD2hnDahz-mDBHIWjTSidYHnxSA,236
-Unidecode-1.3.8.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
-Unidecode-1.3.8.dist-info/LICENSE,sha256=gXf5dRMhNSbfLPYYTY_5hsZ1r7UU1OaKQEAQUhuIBkM,18092
-Unidecode-1.3.8.dist-info/METADATA,sha256=TjOEznFzIHnDfx8CRJjrHfMWiIOOa6drPp6zqa0Obc4,13615
-Unidecode-1.3.8.dist-info/RECORD,,
-Unidecode-1.3.8.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
-Unidecode-1.3.8.dist-info/WHEEL,sha256=Xo9-1PvkuimrydujYJAjF7pCkriuXBpUPEjma1nZyJ0,92
-Unidecode-1.3.8.dist-info/entry_points.txt,sha256=zjje8BrCWj_5MDf7wASbnNdeWYxxdt5BuTQI9x8c_24,50
-Unidecode-1.3.8.dist-info/top_level.txt,sha256=4uYNG2l04s0dm0mEQmPLo2zrjLbhLPKUesLr2dOTdpo,10
-unidecode/__init__.py,sha256=uUP370Iden1EsQtgglNd57DMKOG5mXh9UxIMm8yhDfQ,4230
-unidecode/__main__.py,sha256=VWYWCclyJsdhtNMQtryMFbgsCZtNUsWcEuS7ZOlH1Jc,40
-unidecode/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
-unidecode/util.py,sha256=ZxssZFzbZlAf6oiDIu2HZjrAQckbOD2VPD9uy-wZgCI,1652
-unidecode/x000.py,sha256=DaoVzSCvFzhzHbFtzFOE8uS9CgWD7K3JuhmACpFbivY,3038
-unidecode/x001.py,sha256=ylHh3UVaPtibVuUEEWvdSeDFK0OXrWt4-LnxAgYD6qo,3891
-unidecode/x002.py,sha256=NWord8myi2jYV4YwlNZFbKu6HgbbolWLNCOlseR3WsY,3871
-unidecode/x003.py,sha256=5gZS5aXbQ4Z8aH08EehKx4SqAgUNBcTz_x-I3o5qvVg,3825
-unidecode/x004.py,sha256=KAeJjKgkdzMU1MK9J9JqmPeKBDgjhG5UcfyAa594Hk8,4054
-unidecode/x005.py,sha256=7ezPyF52iKiK5LPf6TA5zVUZ7RbIjz7EVLS42aXG9ug,3920
-unidecode/x006.py,sha256=Jye83eXYQqtpowxsQ01jQSDlhAjWbmGNFRdmbojvgyE,3912
-unidecode/x007.py,sha256=6lnnnArEmvi3XeZLFwrCZGStdDKDAHt7alIpdo8S7rk,3987
-unidecode/x009.py,sha256=xNz8qrO1PDseMjOwA0rjsiAhNZTO_uFgjpmbp7qcH_c,4013
-unidecode/x00a.py,sha256=2xksKrrMWF9xLbs8OPfTxT7g86ciwdK9QZ8AQeecmus,4019
-unidecode/x00b.py,sha256=Y7GlfYE2v-D3BkZd3ctfo6L21VG-aR2OFESRb8_WRH4,4019
-unidecode/x00c.py,sha256=jOGpNU7vxghp3jwUuUATiSrDwvgZuOe8nlkcjJYTHco,4007
-unidecode/x00d.py,sha256=lkFf8d_oXN8IZop6CFlYpKdWuJqWGnH0WQMwir4_WgI,4025
-unidecode/x00e.py,sha256=ARKK__sIXUXL4h2Egac2f9ng2Z_YCGD5kYP2oj-ptlI,3989
-unidecode/x00f.py,sha256=TdSmr755Jw1TRtxk5Z4UPZIp1CVhXii8S0zSAcQ2vWk,3998
-unidecode/x010.py,sha256=YhXX8s1dP7YJMzaaV9CMBCOraExb6QrQQWbkFT3d2Jo,4011
-unidecode/x011.py,sha256=bc5lAse0haio2pceaADqkjzTh8MdgNTwTh04W2FJO-Q,4120
-unidecode/x012.py,sha256=XoiRFvNtHV29Q76KcpPBSrC4sLd6faTz4tKZEMIQ45M,4293
-unidecode/x013.py,sha256=UkxSb2Q4xq7dydCZNg_f0Nu90slVSmAckq-btDZ7uAA,4190
-unidecode/x014.py,sha256=4R3w_Dgg9yCw-9KkpqHfWFzyQZZfdb444fMIh240l-Q,4298
-unidecode/x015.py,sha256=TB6O4l2qPxbmF2dejlxXLqX5tTfjl95cMYx1770GHs0,4329
-unidecode/x016.py,sha256=Tx3P-DjDqCLuKbmiG-0cMzw2xFVuojQg3o5yyt4506E,4114
-unidecode/x017.py,sha256=Ks_t-4BgOrTqmqYC6BpqXePI-YyStE7p3P27lzBefSA,4038
-unidecode/x018.py,sha256=C1jpnsK3YO27xpiWJ2DXSAkV9dsPUwKqWtkgePtzp3g,3998
-unidecode/x01d.py,sha256=EwAYkMVHAFvbKRzsQ-e4cRcvS_eia3kYCM2GcaqkBWY,3701
-unidecode/x01e.py,sha256=rG1jtL0dpL-RNsvG-AxX1izkyWkbgwe0BWhATDJtmgg,3845
-unidecode/x01f.py,sha256=NUC2rlFE9YpODdDn4e5uzV7uIqEBNvKw486nOD7UQpQ,3877
-unidecode/x020.py,sha256=lXj8wkWMbD2Iuw3OCrEqZofJjJccnvY3ro5SpyotCq8,4080
-unidecode/x021.py,sha256=QQGWXFmQhQ9ei6rVCx2y-pbx_-7n8bv9DGJpdK_Q8jc,3987
-unidecode/x022.py,sha256=wX6BUR7yKGgSICIzY_B15mqgnjvRbSlepM6aqb2tnGY,4085
-unidecode/x023.py,sha256=weebXXqY3E8OhqS0ziAKHo58lCl3dkkyD0w2aKHqv7Q,4089
-unidecode/x024.py,sha256=JmCTFnYtmMHQvfYP-4f5uDiCxlwhNk7LZLyxLWWGjK8,4003
-unidecode/x025.py,sha256=DAMdCakIv0m21AWcRUNK9QWReCYXPSwVDmbFdriM4qc,3854
-unidecode/x026.py,sha256=TKU0cwRXL8vLAmZ26R8E2dpkmXmRKx4wTU0VEbuTAnM,3874
-unidecode/x027.py,sha256=qZacxfhS5nWgBhbrIT6-wm9yGP_OlAVRJ-GcmUhPl14,3718
-unidecode/x028.py,sha256=FZPCZ9w3N3WOI42h2gHEQgVOAlLBNTZjMu_KQQkIMdk,5069
-unidecode/x029.py,sha256=b8afmG-DjZmHHy0XdjcZlSXtlnwjScIcPBGbMv_YSUQ,4090
-unidecode/x02a.py,sha256=QHAyHnegV0OVOTQ5OnfJKzkaHQIFbWmmMjiFcHGUZi0,4093
-unidecode/x02c.py,sha256=ZkmMztaYT7d81E9qtUU9ayG9hBi5XqWY_ta-X5Hsaqc,4076
-unidecode/x02e.py,sha256=VCGlK7123S2wDzfkggEARyGZKi-0ElepSYECGGluf7E,4072
-unidecode/x02f.py,sha256=hcUTlkw_6Hjnxsk0e28RTd-HWpSK0IGq5hkrwA1fJFk,4091
-unidecode/x030.py,sha256=wdodiC_N7bMsh8vSmVF0STHGZnOAsZnVN-_RPiqupRA,4028
-unidecode/x031.py,sha256=jed0xoqQmUnnOqATVe7z9F2zigAZVAJX6BrWtXFPWbs,4044
-unidecode/x032.py,sha256=lj4IwokKA0IdIJiJJTfmBUGVYmWvLowFtPLwLzhfokU,4466
-unidecode/x033.py,sha256=ImTd4BRRPgCqWmrvJPoikoL0dJMKH8eQgd48vksi60A,4513
-unidecode/x04d.py,sha256=hcUTlkw_6Hjnxsk0e28RTd-HWpSK0IGq5hkrwA1fJFk,4091
-unidecode/x04e.py,sha256=X-Pzl5_QGkYexzNTY04C_tq3RvbyAUYemf0C4mIl5-U,4630
-unidecode/x04f.py,sha256=BM29-2OTb6aR7CN7NMN3nnC9BGxgediLEHGMcIB5ENU,4597
-unidecode/x050.py,sha256=SPmkA-PD39V8eO4DByxVa8HyqanGcw54xW51kLnaieY,4676
-unidecode/x051.py,sha256=GGJT-fiYxTk_FAAW6eTobT3pOGI-Qq1M3eCxN7c7f5E,4681
-unidecode/x052.py,sha256=a09eo_5pL6jpU9TW-zG2w2iXTYp6awtQ4OxGnLdcwKg,4654
-unidecode/x053.py,sha256=4x8X4Hrf56DOAINYi8JxStXW4m7FGJNiH-51JzCxE64,4608
-unidecode/x054.py,sha256=N8hO8YrlNoepnrYLUZ_EcTVRqI1lekqq3h-i-UNlTJw,4577
-unidecode/x055.py,sha256=_PK65HLpk7puojAFGeOm5Cdk-PDevHHI6NR8sHuo0Ko,4595
-unidecode/x056.py,sha256=mlNNouWFIjpZdjuBWhxFGSB_UDh0OItlsShjHQRjhxc,4607
-unidecode/x057.py,sha256=uivN7P3d-kkonqBATLKOM0ni4jVvsSzA9SOEFhbOuP4,4627
-unidecode/x058.py,sha256=lPNpdrFLFfaBoQz8Cwm2Ess8m4m_45ylIHspOUpDrLk,4664
-unidecode/x059.py,sha256=BdA_NFQqr-aGpuyo9he6uxDwm9facV-ql5axiKqgByk,4640
-unidecode/x05a.py,sha256=9UFNWH8FpkHUArS2-Td3VYOo21VQkoqYW7A0Slk0YhQ,4632
-unidecode/x05b.py,sha256=yfWnRe6mtnqY3b3Ac2_IJBA5vBYb64PYF9XM4HSZygU,4666
-unidecode/x05c.py,sha256=6iZj6HHnJ4lF3k1i68-9Dgge2H3KAlyZtNxW0BIu66o,4602
-unidecode/x05d.py,sha256=Wudbb7xOtWry4Xu5xm9j80vFkigCedGq5uHcYAYl0o8,4660
-unidecode/x05e.py,sha256=wKqvr0lkEy1yfXbYj2OtXHBxw5FxVz_MzJULXWrGvA0,4662
-unidecode/x05f.py,sha256=NnSIJOl_9CC4IRwBIQ6CEhTfvvzZ2PXhZSLJuC6sgHY,4656
-unidecode/x060.py,sha256=-Ajr6Q7RP_fdetvZ2hWflxNiaOokB3q5oeRCt7CqcDc,4640
-unidecode/x061.py,sha256=aqOY7Jt--4JhdktU2RB1bf5J0fH27fRDLhV55aR3gO0,4656
-unidecode/x062.py,sha256=wxQkvAGrppx4Y5E-hAVCps0I9bz_fbG1YSqs1E8k9sU,4616
-unidecode/x063.py,sha256=wAcyLr9CJ35G4sNTfvYb7DtFjeRlyo585JC2_-aBuQM,4648
-unidecode/x064.py,sha256=8e775dKt12GedypWT9jPXeqWLkW5-AsVG106FlfiTvA,4651
-unidecode/x065.py,sha256=fPak6ADqEOBFPfP2u7pAIZ_ObbgtdGFa4enmjVBpsVE,4634
-unidecode/x066.py,sha256=K6g6XTVEFEAppiln64vxgA2V1FMWl0YdbhDJgihQsTA,4675
-unidecode/x067.py,sha256=5d8zLxoh2vS76uBWQckXGbeyjzEUJ5aJMAMvNA-YxLs,4627
-unidecode/x068.py,sha256=-UhVYRQGQtxQJbgwyHAox-JHizu_RvO7Lb5I1F9mpvY,4670
-unidecode/x069.py,sha256=cRQZP6ZGJQsx5l2qSfpe9XmiDfxlGh7rEh30_u9oTSo,4665
-unidecode/x06a.py,sha256=iXZkuxRRsgUuNlVlNliR7gio4M4WUN0JNCPdINrzYlY,4662
-unidecode/x06b.py,sha256=5GRxv36m9zR163UNrGb_c64-uueKrpqyeeRWG9ZDme0,4600
-unidecode/x06c.py,sha256=RNKzdImtimBIuLtvbsUAzYSV7iZmVvPWyV8dj91KJlw,4637
-unidecode/x06d.py,sha256=jFvmxRU4VHSeoahyFtHIHqpvfqvJbNzvsrDn4Kd7WAQ,4647
-unidecode/x06e.py,sha256=1esJUSaQ4QotdjhxG6vtvC3CDWjY2rTr4EVLD4if8CU,4630
-unidecode/x06f.py,sha256=s7JTw6eW_6pqjCc1DEMDQ178vtllhHiejtvb360vDVc,4638
-unidecode/x070.py,sha256=oLeIanQmBbyz8OU_l5VQ-POF8mY5XbLL3rfEjr3XkUw,4677
-unidecode/x071.py,sha256=v1S9E-H06WC0rr10gP27Dqev2nxRlymECJ681BSs9Y4,4644
-unidecode/x072.py,sha256=veZOktQoJQ2wmKKLjq17UM5hAa3xo3nRLdFgSHjv8rI,4645
-unidecode/x073.py,sha256=NWkyVIbNgSu_U9katu1LRaLkL7iHx4bSuRtfsqRG4yk,4642
-unidecode/x074.py,sha256=AocniPNZMcBTeiDWA6OLzQilcWMc_ZHh7pCGXTzqMSg,4686
-unidecode/x075.py,sha256=P3SrhI5BQ5sJ66hyu_LWDONpuzLZJBKsl7f-A37sJXc,4675
-unidecode/x076.py,sha256=9rwfe41pej250BneHHO663PU9vVWyrnHRnP11VUqxEc,4635
-unidecode/x077.py,sha256=ugbmqiry2-tBstXW0Q9o7XEZQimpagZK1EttvBCK1sE,4673
-unidecode/x078.py,sha256=NxeTS_dXa6jmc7iDVUve6_SqO4AhjULng_Gei7pqbRE,4630
-unidecode/x079.py,sha256=ucPPGrgm-AnnWdVFd__unqiSMtdEpZQF6E8ta6IzdiQ,4590
-unidecode/x07a.py,sha256=fjyeO--0F5Kd80F0yOvFIIuiDW7lFKWaVIUqQRIwR9k,4659
-unidecode/x07b.py,sha256=3g39Yw2ZMs7_tcC3OT2e4nGxaWMY6V8iJ2Z6PsnhPS4,4667
-unidecode/x07c.py,sha256=Cbs98r7vdJD_YxpXgAAYoPdA7RDYR82MXN44TQJxoN8,4647
-unidecode/x07d.py,sha256=EKFrTQTNFLGnsm3qI76ALxrxGCcDuyEbapi9j9jy1B4,4678
-unidecode/x07e.py,sha256=r96YBkHoCO8GAvO0j3BdY45RdlNkqpiFWl-Q6mieVcc,4680
-unidecode/x07f.py,sha256=MNRU4aNOE2dKl4p0_WPy-oga_cx7wZ6w4Mlk-RN3PeU,4658
-unidecode/x080.py,sha256=9feIVoCdOFolKgZfRCpdL80l9kRvjbl0z9sV4FAk2Qg,4643
-unidecode/x081.py,sha256=ffvplClKTCDre83MhO7-X3tgdUWfjvkUMxQCPEnRj_U,4671
-unidecode/x082.py,sha256=XTFSjZO8LO3SFcYh9h-Oqby6a67hFDx4B-AQRyptlJU,4641
-unidecode/x083.py,sha256=wXP1lZZAravJZm1f1bCT1cumocFGRG0ZQmgFMVCOSDQ,4635
-unidecode/x084.py,sha256=inA5ODar8zAherLeTyX9-KtCUOrTigxDwb3ei2Kl1CE,4630
-unidecode/x085.py,sha256=QDKK-wbb04nCFc91pSGhyHsxcS_MhdeQLsRqqXhV9h8,4628
-unidecode/x086.py,sha256=DcXhJemXKgrGwPBRFCbINxfxatqjpy7jFgM9jbN8eEk,4608
-unidecode/x087.py,sha256=nddqMqheth-n7kHCyjRNvVPO82UI_PdOic1kQer_iF0,4641
-unidecode/x088.py,sha256=0PVL160fpQ-Kkw29X-bLviyfs4TKIAwp_-SwEWsvemM,4639
-unidecode/x089.py,sha256=RrIGIX6dojryaYh6Da4ysaM_-yREbNZ-HasFX2O_SQc,4624
-unidecode/x08a.py,sha256=NjMp9ck824PXG2gcJXfi_9oQCFgXhhiallO3bYCtXCE,4647
-unidecode/x08b.py,sha256=vUwkG_IOBIhB8gQAaVbgD5EAIA1wY4BBPk5kXwAcPg0,4639
-unidecode/x08c.py,sha256=0sHcCXB9YzXE9oJcwzPtPUltCn6Oo-itdY5vk6MbtdA,4628
-unidecode/x08d.py,sha256=SWD7xSIR-1P30S5-yuNDHpVjWlpfxmUxuJr7f178WsA,4630
-unidecode/x08e.py,sha256=Ym0RQUdsgZJdVmOI56nzSmzfxuKjuS5MUbPSOeyv2Ws,4655
-unidecode/x08f.py,sha256=tNFpnEzNLIY4xHbcR0rZqaoNUKinj-XO2XfSnh6c4u4,4649
-unidecode/x090.py,sha256=XGomJNriNZsHQRUDy3vKwFc4W38uxeqWpn5SHM4G4j8,4627
-unidecode/x091.py,sha256=u8tRZhaVNa2mbsDSYIKqRZ3u4Npj-kiz55rC9izadnM,4653
-unidecode/x092.py,sha256=NvNce8y3YFlPI20pN1a4LY68sid5ApetXs9bo9cxb7w,4644
-unidecode/x093.py,sha256=O2e1p58RB1TS2Au-JSjft3FgPBx1YRAGxnviqSsfnYE,4646
-unidecode/x094.py,sha256=k8ZwNc9qCSzU2b8wMrWUeGSg39tPMiwiKHCiKw6zteM,4653
-unidecode/x095.py,sha256=H2O3xJDE3cAOecyYMUTl6fLs9shETPFwZshtIIK5V3E,4667
-unidecode/x096.py,sha256=sev3zRm46EBQgEtkR4T-Ah0cHYEM-9CM2pFCCc21BFI,4608
-unidecode/x097.py,sha256=S1nZBdt-MHySCAgV9MDdNSQTCSaD62iAhz8EjikfS5A,4633
-unidecode/x098.py,sha256=w0KMxUF7XyG9gdfTJylYsG_clvm3RD_LIM5XHR0xsdY,4643
-unidecode/x099.py,sha256=nlaWb2nRTSnFfDjseDRJ1a3Y0okOHbNA1-htKo9SkAM,4627
-unidecode/x09a.py,sha256=Z8pQsTc62CWgm0JPnj3kokKKf9_qfzRpo0u5iH61CaE,4623
-unidecode/x09b.py,sha256=njA75MlCgC-5UuS1Hvm-mdgsVwW9r801odfBTJg-BFE,4653
-unidecode/x09c.py,sha256=NveMhN85_Cm4H1cnfHDTcnSj675MOVBq9Lkjpw3YxA0,4659
-unidecode/x09d.py,sha256=_0fAaUhK3axhhWLA4QPNJf_J9YSs1MCKx2xR-vl5QYI,4630
-unidecode/x09e.py,sha256=wreETFCeKf9bVvLc3E7MUAvlu2CN5xKeebf3ESuV13s,4613
-unidecode/x09f.py,sha256=pNAdX7-9yMEPXtozjCuXxzc74eCVft9meOTxCtU7vJw,4420
-unidecode/x0a0.py,sha256=EpopPuuocybgCcpX19Ii-udqsPXJjSces3360lqJ8vs,4428
-unidecode/x0a1.py,sha256=0hvF77d5E640SujjdHVqy5gMUH85gEdOv80eRvCEAGM,4469
-unidecode/x0a2.py,sha256=9Icpfk_ElebYd_xN09OMziFrpAGPXEUNVmawpnhbBaQ,4503
-unidecode/x0a3.py,sha256=G1lPrnCqYz0s4wsSa1qM0WgrZBWO_beRk3AgK0iVZLA,4521
-unidecode/x0a4.py,sha256=nWPXzCragW0rsDQPa6ooo9KOc-SOjVCP8KIOuGc7WpU,4373
-unidecode/x0ac.py,sha256=wj7hl88VlCdc_eGpOL4m4CBJILyQqd9atObC5Xvd0aA,4709
-unidecode/x0ad.py,sha256=Rz5rn0fM-CqRjaN4TvSq_1StAQdyAF2WX3cUvcQHaWU,4766
-unidecode/x0ae.py,sha256=jNIBVB-Pw2ZNihAeyWbDIEq9Yt9zlhdfGylfvAaxUks,4875
-unidecode/x0af.py,sha256=Am5YC8Zfrun5NUKxU6LrU2-d5GgkGSBs7fZt2rqSi74,5012
-unidecode/x0b0.py,sha256=1bgHerCDAqIcJHYeGddJjJfRWiHCKtU2B0J-XGvcbbc,4853
-unidecode/x0b1.py,sha256=Six-lzGdvgJx4YsIa0lTusnBEV1zbCKQCquq17TDJoQ,4746
-unidecode/x0b2.py,sha256=HQDbmglNi4QfiRSGucUclgq_4FGpRjbJkWU1JTLAFGc,4680
-unidecode/x0b3.py,sha256=1lqxghVZiiStOAx1IG_vc1zZTXrAa7Z__QY6ZWvo2aA,4741
-unidecode/x0b4.py,sha256=V6BNSTxpyP8VuqF7x5z7bpF3MQAkwZfKtEu6NFr_vSg,4762
-unidecode/x0b5.py,sha256=9NVd2hNLyRlLceVlznba1dreqBGeKU_0gzmkgAw0gyg,4919
-unidecode/x0b6.py,sha256=V_vRsB0GICu9hqhO4pnbPWreDSevJ3bbmLRJkuQUxnE,4996
-unidecode/x0b7.py,sha256=CwBaCBICyVagnFjUpkwabuDvBJw7gAeqkSRpfBAVv8s,4833
-unidecode/x0b8.py,sha256=xYp-xy2LIwq95OWyS9vYMc_Z5od9dud0W1dxeg4P_Jk,4714
-unidecode/x0b9.py,sha256=z3hKNzBq_MeK9V3AyQzaY58cgi0-VGOsLk3-UFmszLQ,4704
-unidecode/x0ba.py,sha256=4gubifoBeJUUrwXEI4litJygekufEycmWDLrJ-Qvs14,4765
-unidecode/x0bb.py,sha256=bsCTABUdC6yTn8_0vhYe5jRP1z_BoAdificB8Y1c1hA,4730
-unidecode/x0bc.py,sha256=AhQvAz7yHlbQ_4c2KOIisq07eZJ5JQn6cV8I31oT9kg,4707
-unidecode/x0bd.py,sha256=IGtyVxIUr1mU3hokn6iUDJhXZezQozVvfWOyf4Pa5dI,4752
-unidecode/x0be.py,sha256=1D-hXu3p3wvOnGVMjEqVsrltYe7UuSwit2yqN5eFizc,4849
-unidecode/x0bf.py,sha256=NkEXqr2ER3BNFkTasDV9CHnkRBuX_Ao5OHGv_NgKAew,5010
-unidecode/x0c0.py,sha256=zDlHpyM0omza5TqGLb8Rhl7Wd-LlV1AjvH_xdnEnNFw,4856
-unidecode/x0c1.py,sha256=AC6xJyx9UblKAGNqGN7AH2Idb3_3vbc-I5U0Myig5fA,4765
-unidecode/x0c2.py,sha256=siRYLA8Cv9Z8XsRp3WQOBdRrPkjJOuEh8z1-3SMXOzQ,4710
-unidecode/x0c3.py,sha256=hlAFe6lsz0aLMixlpeFjV4I-WTIiA3B2BU58yGlTwRg,4975
-unidecode/x0c4.py,sha256=z3xZwSkf5ru1FCdBMHOr5fyglzVdyPhQVtWjq9xInsQ,5024
-unidecode/x0c5.py,sha256=F-DR0eVMRkemOnNXOtDjI5i6gW9136XLmWM_yMVvc84,4581
-unidecode/x0c6.py,sha256=7p_jMrHf3WUa_zANms-RGVN1bAeshgWLkC16_VcSawA,4490
-unidecode/x0c7.py,sha256=5eOAq4jFsPZ-zKO7lHzAGj_EvXdaMC4Kud7gvE-B7Tg,4564
-unidecode/x0c8.py,sha256=wltKvhBgn51jULzwUnEbmyDuK9JvQpQee0uTKK42-20,4733
-unidecode/x0c9.py,sha256=GoARON07wCoHN2wRHb5fvzqE9L3Yme2hKeciynUIAIk,4722
-unidecode/x0ca.py,sha256=BsBZTNj3npIkdo3L9pSEX7XvDT68KV7wFtOOwyEb2So,5007
-unidecode/x0cb.py,sha256=8T7vnJMRmYGyySYthMWz0bgN-MremktGImjejodFeMo,5012
-unidecode/x0cc.py,sha256=GKoHN-4vL4Y3EL42G0xbN74Tgspew1oMvxQtsIa3ess,4749
-unidecode/x0cd.py,sha256=7sZ05OjugbaombMRDYOVxgstZbXMcuX5kHFheKv4W2E,4738
-unidecode/x0ce.py,sha256=mOEHFrsAwIvcTnh7OKVK5qbuXUXHfJOR7D4FtXsQmao,4708
-unidecode/x0cf.py,sha256=H9PeYcbOG68F_yc7zsELUuN05ANfFNOUX-e3-gzx7Ow,4713
-unidecode/x0d0.py,sha256=eULqcGHPmaoEdl0EwRB5wWSu8M43bp4HoFo5gGljacg,4706
-unidecode/x0d1.py,sha256=BClLDAjPgsAX6MJCsuHfmfuhH9qfzUy_vb-d9zBs3Oc,4767
-unidecode/x0d2.py,sha256=e74nqGo4E4sF1sy8qBFu2ecWoRfJdoXI1xRFRPqYEz8,4724
-unidecode/x0d3.py,sha256=8-UmvJ3-ILXo9d3GA-ReOE4OfUenL3tVUJYldZ9gHu0,4705
-unidecode/x0d4.py,sha256=fwUmzksoddTKB8fH2rZMxRK3pJtLrxhcrYpHfBauAwE,4758
-unidecode/x0d5.py,sha256=rANSL5ndzLgSgYJQNEw57AfXpicRe7pvHRlKTPb4-QQ,4680
-unidecode/x0d6.py,sha256=fT8_cRzp7y60IIhn87kM9lLehKGAg5wYmfFOwgGp6e0,4765
-unidecode/x0d7.py,sha256=40-m7uKNvylWCcVBuTXrbiP6Lrj_4d4PWgLcX8670Kk,4468
-unidecode/x0f9.py,sha256=2PD0_fpDnaFO9ftICjYSOhnjAfBppjsj1TcLIuYjnCI,4567
-unidecode/x0fa.py,sha256=XHxCfXOhHDqzjG0Nw6n1sT5Q_MKLCovPFe-22IQxVXU,4172
-unidecode/x0fb.py,sha256=n_5urRXj6Ecf0MKMnuwNY0UK6TJtUW2hKcNLQqa2Gf8,3787
-unidecode/x0fc.py,sha256=KcyQnyv7gxNeVcAnRwQrm4NlabZE3CrnmtLqXj_7te8,3595
-unidecode/x0fd.py,sha256=mVHMrX8AhRzwCkMNA4sJkhwirK3BqmNv6YZfyCpE9Is,3703
-unidecode/x0fe.py,sha256=CrdwUOf0sl8yUfOFnXOXFZ8U662dQThpGMwGBkY8cJ4,3769
-unidecode/x0ff.py,sha256=Ijfv5VVDCTWRzRqwMYSp0fSycy176gn7P8ut8x3bv-w,3957
-unidecode/x1d4.py,sha256=xzL0OicR95IWq6LiApIPEgPoST8dyVgYuIUGxkz1b28,3863
-unidecode/x1d5.py,sha256=bmTSTgWnsLP7yUDZq2Irtz84Zm7bmLzYzurY0eI0uIU,3863
-unidecode/x1d6.py,sha256=8H0RmEfbY82X1iQwr0vcsgQGCvGKv19_773K_T2NI2A,4052
-unidecode/x1d7.py,sha256=yyHV2dCo1p_m_QVgz1H9S6XqeiN9GpGxB-ZqAW7l5ts,4057
-unidecode/x1f1.py,sha256=URX9F6UPgUo4-tpr7bhPm4G5ruFDoScW5bZLwzR88Yg,4308
-unidecode/x1f6.py,sha256=Ji4t-EFmJmo3CDeZ0yD7pX58hj5fQQc99TOrD-yad9k,4103
diff --git a/libs/Unidecode-1.3.8.dist-info/WHEEL b/libs/Unidecode-1.3.8.dist-info/WHEEL
deleted file mode 100644
index ba48cbcf92..0000000000
--- a/libs/Unidecode-1.3.8.dist-info/WHEEL
+++ /dev/null
@@ -1,5 +0,0 @@
-Wheel-Version: 1.0
-Generator: bdist_wheel (0.41.3)
-Root-Is-Purelib: true
-Tag: py3-none-any
-
diff --git a/libs/alembic-1.14.0.dist-info/LICENSE b/libs/alembic-1.14.0.dist-info/LICENSE
deleted file mode 100644
index be8de0089e..0000000000
--- a/libs/alembic-1.14.0.dist-info/LICENSE
+++ /dev/null
@@ -1,19 +0,0 @@
-Copyright 2009-2024 Michael Bayer.
-
-Permission is hereby granted, free of charge, to any person obtaining a copy of
-this software and associated documentation files (the "Software"), to deal in
-the Software without restriction, including without limitation the rights to
-use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
-of the Software, and to permit persons to whom the Software is furnished to do
-so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
diff --git a/libs/alembic-1.14.0.dist-info/METADATA b/libs/alembic-1.14.0.dist-info/METADATA
deleted file mode 100644
index 2f6963fc66..0000000000
--- a/libs/alembic-1.14.0.dist-info/METADATA
+++ /dev/null
@@ -1,142 +0,0 @@
-Metadata-Version: 2.1
-Name: alembic
-Version: 1.14.0
-Summary: A database migration tool for SQLAlchemy.
-Home-page: https://alembic.sqlalchemy.org
-Author: Mike Bayer
-Author-email: mike_mp@zzzcomputing.com
-License: MIT
-Project-URL: Documentation, https://alembic.sqlalchemy.org/en/latest/
-Project-URL: Changelog, https://alembic.sqlalchemy.org/en/latest/changelog.html
-Project-URL: Source, https://github.com/sqlalchemy/alembic/
-Project-URL: Issue Tracker, https://github.com/sqlalchemy/alembic/issues/
-Classifier: Development Status :: 5 - Production/Stable
-Classifier: Intended Audience :: Developers
-Classifier: Environment :: Console
-Classifier: License :: OSI Approved :: MIT License
-Classifier: Operating System :: OS Independent
-Classifier: Programming Language :: Python
-Classifier: Programming Language :: Python :: 3
-Classifier: Programming Language :: Python :: 3.8
-Classifier: Programming Language :: Python :: 3.9
-Classifier: Programming Language :: Python :: 3.10
-Classifier: Programming Language :: Python :: 3.11
-Classifier: Programming Language :: Python :: 3.12
-Classifier: Programming Language :: Python :: Implementation :: CPython
-Classifier: Programming Language :: Python :: Implementation :: PyPy
-Classifier: Topic :: Database :: Front-Ends
-Requires-Python: >=3.8
-Description-Content-Type: text/x-rst
-License-File: LICENSE
-Requires-Dist: SQLAlchemy >=1.3.0
-Requires-Dist: Mako
-Requires-Dist: typing-extensions >=4
-Requires-Dist: importlib-metadata ; python_version < "3.9"
-Requires-Dist: importlib-resources ; python_version < "3.9"
-Provides-Extra: tz
-Requires-Dist: backports.zoneinfo ; (python_version < "3.9") and extra == 'tz'
-
-Alembic is a database migrations tool written by the author
-of `SQLAlchemy `_. A migrations tool
-offers the following functionality:
-
-* Can emit ALTER statements to a database in order to change
- the structure of tables and other constructs
-* Provides a system whereby "migration scripts" may be constructed;
- each script indicates a particular series of steps that can "upgrade" a
- target database to a new version, and optionally a series of steps that can
- "downgrade" similarly, doing the same steps in reverse.
-* Allows the scripts to execute in some sequential manner.
-
-The goals of Alembic are:
-
-* Very open ended and transparent configuration and operation. A new
- Alembic environment is generated from a set of templates which is selected
- among a set of options when setup first occurs. The templates then deposit a
- series of scripts that define fully how database connectivity is established
- and how migration scripts are invoked; the migration scripts themselves are
- generated from a template within that series of scripts. The scripts can
- then be further customized to define exactly how databases will be
- interacted with and what structure new migration files should take.
-* Full support for transactional DDL. The default scripts ensure that all
- migrations occur within a transaction - for those databases which support
- this (Postgresql, Microsoft SQL Server), migrations can be tested with no
- need to manually undo changes upon failure.
-* Minimalist script construction. Basic operations like renaming
- tables/columns, adding/removing columns, changing column attributes can be
- performed through one line commands like alter_column(), rename_table(),
- add_constraint(). There is no need to recreate full SQLAlchemy Table
- structures for simple operations like these - the functions themselves
- generate minimalist schema structures behind the scenes to achieve the given
- DDL sequence.
-* "auto generation" of migrations. While real world migrations are far more
- complex than what can be automatically determined, Alembic can still
- eliminate the initial grunt work in generating new migration directives
- from an altered schema. The ``--autogenerate`` feature will inspect the
- current status of a database using SQLAlchemy's schema inspection
- capabilities, compare it to the current state of the database model as
- specified in Python, and generate a series of "candidate" migrations,
- rendering them into a new migration script as Python directives. The
- developer then edits the new file, adding additional directives and data
- migrations as needed, to produce a finished migration. Table and column
- level changes can be detected, with constraints and indexes to follow as
- well.
-* Full support for migrations generated as SQL scripts. Those of us who
- work in corporate environments know that direct access to DDL commands on a
- production database is a rare privilege, and DBAs want textual SQL scripts.
- Alembic's usage model and commands are oriented towards being able to run a
- series of migrations into a textual output file as easily as it runs them
- directly to a database. Care must be taken in this mode to not invoke other
- operations that rely upon in-memory SELECTs of rows - Alembic tries to
- provide helper constructs like bulk_insert() to help with data-oriented
- operations that are compatible with script-based DDL.
-* Non-linear, dependency-graph versioning. Scripts are given UUID
- identifiers similarly to a DVCS, and the linkage of one script to the next
- is achieved via human-editable markers within the scripts themselves.
- The structure of a set of migration files is considered as a
- directed-acyclic graph, meaning any migration file can be dependent
- on any other arbitrary set of migration files, or none at
- all. Through this open-ended system, migration files can be organized
- into branches, multiple roots, and mergepoints, without restriction.
- Commands are provided to produce new branches, roots, and merges of
- branches automatically.
-* Provide a library of ALTER constructs that can be used by any SQLAlchemy
- application. The DDL constructs build upon SQLAlchemy's own DDLElement base
- and can be used standalone by any application or script.
-* At long last, bring SQLite and its inability to ALTER things into the fold,
- but in such a way that SQLite's very special workflow needs are accommodated
- in an explicit way that makes the most of a bad situation, through the
- concept of a "batch" migration, where multiple changes to a table can
- be batched together to form a series of instructions for a single, subsequent
- "move-and-copy" workflow. You can even use "move-and-copy" workflow for
- other databases, if you want to recreate a table in the background
- on a busy system.
-
-Documentation and status of Alembic is at https://alembic.sqlalchemy.org/
-
-The SQLAlchemy Project
-======================
-
-Alembic is part of the `SQLAlchemy Project `_ and
-adheres to the same standards and conventions as the core project.
-
-Development / Bug reporting / Pull requests
-___________________________________________
-
-Please refer to the
-`SQLAlchemy Community Guide `_ for
-guidelines on coding and participating in this project.
-
-Code of Conduct
-_______________
-
-Above all, SQLAlchemy places great emphasis on polite, thoughtful, and
-constructive communication between users and developers.
-Please see our current Code of Conduct at
-`Code of Conduct `_.
-
-License
-=======
-
-Alembic is distributed under the `MIT license
-`_.
diff --git a/libs/alembic-1.14.0.dist-info/RECORD b/libs/alembic-1.14.0.dist-info/RECORD
deleted file mode 100644
index f7cc605d01..0000000000
--- a/libs/alembic-1.14.0.dist-info/RECORD
+++ /dev/null
@@ -1,86 +0,0 @@
-../../bin/alembic,sha256=xqPGhIsDow0IG3BUa3a_VtCtKJgqxLpVJuFe1PQcGoA,236
-alembic-1.14.0.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
-alembic-1.14.0.dist-info/LICENSE,sha256=zhnnuit3ylhLgqZ5KFbhOOswsxHIlrB2wJpAXuRfvuk,1059
-alembic-1.14.0.dist-info/METADATA,sha256=5hNrxl9umF2WKbNL-MxyMUEZem8-OxRa49Qz9w7jqzo,7390
-alembic-1.14.0.dist-info/RECORD,,
-alembic-1.14.0.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
-alembic-1.14.0.dist-info/WHEEL,sha256=P9jw-gEje8ByB7_hXoICnHtVCrEwMQh-630tKvQWehc,91
-alembic-1.14.0.dist-info/entry_points.txt,sha256=aykM30soxwGN0pB7etLc1q0cHJbL9dy46RnK9VX4LLw,48
-alembic-1.14.0.dist-info/top_level.txt,sha256=FwKWd5VsPFC8iQjpu1u9Cn-JnK3-V1RhUCmWqz1cl-s,8
-alembic/__init__.py,sha256=qw_qYmTjOKiGcs--x0c6kjZo70tQTR5m8_lqF98Qr_0,63
-alembic/__main__.py,sha256=373m7-TBh72JqrSMYviGrxCHZo-cnweM8AGF8A22PmY,78
-alembic/autogenerate/__init__.py,sha256=ntmUTXhjLm4_zmqIwyVaECdpPDn6_u1yM9vYk6-553E,543
-alembic/autogenerate/api.py,sha256=Bh-37G0PSFeT9WSfEQ-3TZoainXGLL2nsl4okv_xYc0,22173
-alembic/autogenerate/compare.py,sha256=cdUBH6qsedaJsnToSOu4MfcJaI4bjUJ4VWqtBlqsSr8,44944
-alembic/autogenerate/render.py,sha256=YB3C90rq7XDhjTia9GAnK6yfnVVzCROziZrbArmG9SE,35481
-alembic/autogenerate/rewriter.py,sha256=uZWRkTYJoncoEJ5WY1QBRiozjyChqZDJPy4LtcRibjM,7846
-alembic/command.py,sha256=2tkKrIoEgPfXrGgvMRGrUXH4l-7z466DOxd7Q2XOfL8,22169
-alembic/config.py,sha256=BZ7mwFRk2gq8GFNxxy9qvMUFx43YbDbQTC99OnjqiKY,22216
-alembic/context.py,sha256=hK1AJOQXJ29Bhn276GYcosxeG7pC5aZRT5E8c4bMJ4Q,195
-alembic/context.pyi,sha256=hUHbSnbSeEEMVkk0gDSXOq4_9edSjYzsjmmf-mL9Iao,31737
-alembic/ddl/__init__.py,sha256=Df8fy4Vn_abP8B7q3x8gyFwEwnLw6hs2Ljt_bV3EZWE,152
-alembic/ddl/_autogen.py,sha256=Blv2RrHNyF4cE6znCQXNXG5T9aO-YmiwD4Fz-qfoaWA,9275
-alembic/ddl/base.py,sha256=gazpvtk_6XURcsa0libwcaIquL5HwJDP1ZWKJ6P7x0I,9788
-alembic/ddl/impl.py,sha256=7-oxMb7KeycaK96x-kXw4mR6NSE1tmN0UEZIZrPcuhY,30195
-alembic/ddl/mssql.py,sha256=ydvgBSaftKYjaBaMyqius66Ta4CICQSj79Og3Ed2atY,14219
-alembic/ddl/mysql.py,sha256=kXOGYmpnL_9WL3ijXNsG4aAwy3m1HWJOoLZSePzmJF0,17316
-alembic/ddl/oracle.py,sha256=669YlkcZihlXFbnXhH2krdrvDry8q5pcUGfoqkg_R6Y,6243
-alembic/ddl/postgresql.py,sha256=GNCnx-N8UsCIstfW49J8ivYcKgRB8KFNPRgNtORC_AM,29883
-alembic/ddl/sqlite.py,sha256=wLXhb8bJWRspKQTb-iVfepR4LXYgOuEbUWKX5qwDhIQ,7570
-alembic/environment.py,sha256=MM5lPayGT04H3aeng1H7GQ8HEAs3VGX5yy6mDLCPLT4,43
-alembic/migration.py,sha256=MV6Fju6rZtn2fTREKzXrCZM6aIBGII4OMZFix0X-GLs,41
-alembic/op.py,sha256=flHtcsVqOD-ZgZKK2pv-CJ5Cwh-KJ7puMUNXzishxLw,167
-alembic/op.pyi,sha256=QZ1ERetxIrpZNTyg48Btn5OJhhpMId-_MLMP36RauOw,50168
-alembic/operations/__init__.py,sha256=e0KQSZAgLpTWvyvreB7DWg7RJV_MWSOPVDgCqsd2FzY,318
-alembic/operations/base.py,sha256=JRaOtPqyqfaPjzGHxuP9VMcO1KsJNmbbLOvwG82qxGA,74474
-alembic/operations/batch.py,sha256=YqtD4hJ3_RkFxvI7zbmBwxcLEyLHYyWQpsz4l5L85yI,26943
-alembic/operations/ops.py,sha256=guIpLQzlqgkdP2LGDW8vWg_DXeAouEldiVZDgRas7YI,94953
-alembic/operations/schemaobj.py,sha256=Wp-bBe4a8lXPTvIHJttBY0ejtpVR5Jvtb2kI-U2PztQ,9468
-alembic/operations/toimpl.py,sha256=Fx-UKcq6S8pVtsEwPFjTKtEcAVKjfptn-BfpE1k3_ck,7517
-alembic/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
-alembic/runtime/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
-alembic/runtime/environment.py,sha256=SkYB_am1h3FSG8IsExAQxGP_7WwzOVigqjlO747Aokc,41497
-alembic/runtime/migration.py,sha256=9GZ_bYZ6yMF7DUD1hgZdmB0YqvcdcNBBfxFaXKHeQoM,49857
-alembic/script/__init__.py,sha256=lSj06O391Iy5avWAiq8SPs6N8RBgxkSPjP8wpXcNDGg,100
-alembic/script/base.py,sha256=XLNpdsLnBBSz4ZKMFUArFUdtL1HcjtuUDHNbA-5VlZA,37809
-alembic/script/revision.py,sha256=NTu-eu5Y78u4NoVXpT0alpD2oL40SGATA2sEMEf1el4,62306
-alembic/script/write_hooks.py,sha256=NGB6NGgfdf7HK6XNNpSKqUCfzxazj-NRUePgFx7MJSM,5036
-alembic/templates/async/README,sha256=ISVtAOvqvKk_5ThM5ioJE-lMkvf9IbknFUFVU_vPma4,58
-alembic/templates/async/alembic.ini.mako,sha256=lw_6ie1tMbYGpbvE7MnzJvx101RbSTh9uu4t9cvDpug,3638
-alembic/templates/async/env.py,sha256=zbOCf3Y7w2lg92hxSwmG1MM_7y56i_oRH4AKp0pQBYo,2389
-alembic/templates/async/script.py.mako,sha256=MEqL-2qATlST9TAOeYgscMn1uy6HUS9NFvDgl93dMj8,635
-alembic/templates/generic/README,sha256=MVlc9TYmr57RbhXET6QxgyCcwWP7w-vLkEsirENqiIQ,38
-alembic/templates/generic/alembic.ini.mako,sha256=YcwTOEoiZr663Gkt6twCjmaqZao0n6xjRl0B5prK79s,3746
-alembic/templates/generic/env.py,sha256=TLRWOVW3Xpt_Tpf8JFzlnoPn_qoUu8UV77Y4o9XD6yI,2103
-alembic/templates/generic/script.py.mako,sha256=MEqL-2qATlST9TAOeYgscMn1uy6HUS9NFvDgl93dMj8,635
-alembic/templates/multidb/README,sha256=dWLDhnBgphA4Nzb7sNlMfCS3_06YqVbHhz-9O5JNqyI,606
-alembic/templates/multidb/alembic.ini.mako,sha256=AW1OGb-QezxBY5mynSWW7b1lGKnh9sVPImfGgfXf2EM,3840
-alembic/templates/multidb/env.py,sha256=6zNjnW8mXGUk7erTsAvrfhvqoczJ-gagjVq1Ypg2YIQ,4230
-alembic/templates/multidb/script.py.mako,sha256=N06nMtNSwHkgl0EBXDyMt8njp9tlOesR583gfq21nbY,1090
-alembic/testing/__init__.py,sha256=kOxOh5nwmui9d-_CCq9WA4Udwy7ITjm453w74CTLZDo,1159
-alembic/testing/assertions.py,sha256=ScUb1sVopIl70BirfHUJDvwswC70Q93CiIWwkiZbhHg,5207
-alembic/testing/env.py,sha256=giHWVLhHkfNWrPEfrAqhpMOLL6FgWoBCVAzBVrVbSSA,10766
-alembic/testing/fixtures.py,sha256=nBntOynOmVCFc7IYiN3DIQ3TBNTfiGCvL_1-FyCry8o,9462
-alembic/testing/plugin/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
-alembic/testing/plugin/bootstrap.py,sha256=9C6wtjGrIVztZ928w27hsQE0KcjDLIUtUN3dvZKsMVk,50
-alembic/testing/requirements.py,sha256=dKeAO1l5TwBqXarJN-IPORlCqCJv-41Dj6oXoEikxHQ,5133
-alembic/testing/schemacompare.py,sha256=N5UqSNCOJetIKC4vKhpYzQEpj08XkdgIoqBmEPQ3tlc,4838
-alembic/testing/suite/__init__.py,sha256=MvE7-hwbaVN1q3NM-ztGxORU9dnIelUCINKqNxewn7Y,288
-alembic/testing/suite/_autogen_fixtures.py,sha256=cDq1pmzHe15S6dZPGNC6sqFaCQ3hLT_oPV2IDigUGQ0,9880
-alembic/testing/suite/test_autogen_comments.py,sha256=aEGqKUDw4kHjnDk298aoGcQvXJWmZXcIX_2FxH4cJK8,6283
-alembic/testing/suite/test_autogen_computed.py,sha256=CXAeF-5Wr2cmW8PB7ztHG_4ZQsn1gSWrHWfxi72grNU,6147
-alembic/testing/suite/test_autogen_diffs.py,sha256=T4SR1n_kmcOKYhR4W1-dA0e5sddJ69DSVL2HW96kAkE,8394
-alembic/testing/suite/test_autogen_fks.py,sha256=AqFmb26Buex167HYa9dZWOk8x-JlB1OK3bwcvvjDFaU,32927
-alembic/testing/suite/test_autogen_identity.py,sha256=kcuqngG7qXAKPJDX4U8sRzPKHEJECHuZ0DtuaS6tVkk,5824
-alembic/testing/suite/test_environment.py,sha256=OwD-kpESdLoc4byBrGrXbZHvqtPbzhFCG4W9hJOJXPQ,11877
-alembic/testing/suite/test_op.py,sha256=2XQCdm_NmnPxHGuGj7hmxMzIhKxXNotUsKdACXzE1mM,1343
-alembic/testing/util.py,sha256=CQrcQDA8fs_7ME85z5ydb-Bt70soIIID-qNY1vbR2dg,3350
-alembic/testing/warnings.py,sha256=RxA7x_8GseANgw07Us8JN_1iGbANxaw6_VitX2ZGQH4,1078
-alembic/util/__init__.py,sha256=KSZ7UT2YzH6CietgUtljVoE3QnGjoFKOi7RL5sgUxrk,1688
-alembic/util/compat.py,sha256=RjHdQa1NomU3Zlvgfvza0OMiSRQSLRL3xVl3OdUy2UE,2594
-alembic/util/editor.py,sha256=JIz6_BdgV8_oKtnheR6DZoB7qnrHrlRgWjx09AsTsUw,2546
-alembic/util/exc.py,sha256=KQTru4zcgAmN4IxLMwLFS56XToUewaXB7oOLcPNjPwg,98
-alembic/util/langhelpers.py,sha256=LpOcovnhMnP45kTt8zNJ4BHpyQrlF40OL6yDXjqKtsE,10026
-alembic/util/messaging.py,sha256=BxAHiJsYHBPb2m8zv4yaueSRAlVuYXWkRCeN02JXhqw,3250
-alembic/util/pyfiles.py,sha256=zltVdcwEJJCPS2gHsQvkHkQakuF6wXiZ6zfwHbGNT0g,3489
-alembic/util/sqla_compat.py,sha256=XMfZaLdbVbJoniNUyI3RUUXu4gCWljjVBbJ7db6vCgc,19526
diff --git a/libs/alembic-1.14.0.dist-info/WHEEL b/libs/alembic-1.14.0.dist-info/WHEEL
deleted file mode 100644
index 9b78c44519..0000000000
--- a/libs/alembic-1.14.0.dist-info/WHEEL
+++ /dev/null
@@ -1,5 +0,0 @@
-Wheel-Version: 1.0
-Generator: setuptools (75.3.0)
-Root-Is-Purelib: true
-Tag: py3-none-any
-
diff --git a/libs/Flask_Cors-5.0.0.dist-info/INSTALLER b/libs/alembic-1.18.4.dist-info/INSTALLER
similarity index 100%
rename from libs/Flask_Cors-5.0.0.dist-info/INSTALLER
rename to libs/alembic-1.18.4.dist-info/INSTALLER
diff --git a/libs/alembic-1.18.4.dist-info/METADATA b/libs/alembic-1.18.4.dist-info/METADATA
new file mode 100644
index 0000000000..04d82dce2d
--- /dev/null
+++ b/libs/alembic-1.18.4.dist-info/METADATA
@@ -0,0 +1,139 @@
+Metadata-Version: 2.4
+Name: alembic
+Version: 1.18.4
+Summary: A database migration tool for SQLAlchemy.
+Author-email: Mike Bayer
+License-Expression: MIT
+Project-URL: Homepage, https://alembic.sqlalchemy.org
+Project-URL: Documentation, https://alembic.sqlalchemy.org/en/latest/
+Project-URL: Changelog, https://alembic.sqlalchemy.org/en/latest/changelog.html
+Project-URL: Source, https://github.com/sqlalchemy/alembic/
+Project-URL: Issue Tracker, https://github.com/sqlalchemy/alembic/issues/
+Classifier: Development Status :: 5 - Production/Stable
+Classifier: Intended Audience :: Developers
+Classifier: Environment :: Console
+Classifier: Operating System :: OS Independent
+Classifier: Programming Language :: Python
+Classifier: Programming Language :: Python :: 3
+Classifier: Programming Language :: Python :: 3.10
+Classifier: Programming Language :: Python :: 3.11
+Classifier: Programming Language :: Python :: 3.12
+Classifier: Programming Language :: Python :: 3.13
+Classifier: Programming Language :: Python :: Implementation :: CPython
+Classifier: Programming Language :: Python :: Implementation :: PyPy
+Classifier: Topic :: Database :: Front-Ends
+Requires-Python: >=3.10
+Description-Content-Type: text/x-rst
+License-File: LICENSE
+Requires-Dist: SQLAlchemy>=1.4.23
+Requires-Dist: Mako
+Requires-Dist: typing-extensions>=4.12
+Requires-Dist: tomli; python_version < "3.11"
+Provides-Extra: tz
+Requires-Dist: tzdata; extra == "tz"
+Dynamic: license-file
+
+Alembic is a database migrations tool written by the author
+of `SQLAlchemy `_. A migrations tool
+offers the following functionality:
+
+* Can emit ALTER statements to a database in order to change
+ the structure of tables and other constructs
+* Provides a system whereby "migration scripts" may be constructed;
+ each script indicates a particular series of steps that can "upgrade" a
+ target database to a new version, and optionally a series of steps that can
+ "downgrade" similarly, doing the same steps in reverse.
+* Allows the scripts to execute in some sequential manner.
+
+The goals of Alembic are:
+
+* Very open ended and transparent configuration and operation. A new
+ Alembic environment is generated from a set of templates which is selected
+ among a set of options when setup first occurs. The templates then deposit a
+ series of scripts that define fully how database connectivity is established
+ and how migration scripts are invoked; the migration scripts themselves are
+ generated from a template within that series of scripts. The scripts can
+ then be further customized to define exactly how databases will be
+ interacted with and what structure new migration files should take.
+* Full support for transactional DDL. The default scripts ensure that all
+ migrations occur within a transaction - for those databases which support
+ this (Postgresql, Microsoft SQL Server), migrations can be tested with no
+ need to manually undo changes upon failure.
+* Minimalist script construction. Basic operations like renaming
+ tables/columns, adding/removing columns, changing column attributes can be
+ performed through one line commands like alter_column(), rename_table(),
+ add_constraint(). There is no need to recreate full SQLAlchemy Table
+ structures for simple operations like these - the functions themselves
+ generate minimalist schema structures behind the scenes to achieve the given
+ DDL sequence.
+* "auto generation" of migrations. While real world migrations are far more
+ complex than what can be automatically determined, Alembic can still
+ eliminate the initial grunt work in generating new migration directives
+ from an altered schema. The ``--autogenerate`` feature will inspect the
+ current status of a database using SQLAlchemy's schema inspection
+ capabilities, compare it to the current state of the database model as
+ specified in Python, and generate a series of "candidate" migrations,
+ rendering them into a new migration script as Python directives. The
+ developer then edits the new file, adding additional directives and data
+ migrations as needed, to produce a finished migration. Table and column
+ level changes can be detected, with constraints and indexes to follow as
+ well.
+* Full support for migrations generated as SQL scripts. Those of us who
+ work in corporate environments know that direct access to DDL commands on a
+ production database is a rare privilege, and DBAs want textual SQL scripts.
+ Alembic's usage model and commands are oriented towards being able to run a
+ series of migrations into a textual output file as easily as it runs them
+ directly to a database. Care must be taken in this mode to not invoke other
+ operations that rely upon in-memory SELECTs of rows - Alembic tries to
+ provide helper constructs like bulk_insert() to help with data-oriented
+ operations that are compatible with script-based DDL.
+* Non-linear, dependency-graph versioning. Scripts are given UUID
+ identifiers similarly to a DVCS, and the linkage of one script to the next
+ is achieved via human-editable markers within the scripts themselves.
+ The structure of a set of migration files is considered as a
+ directed-acyclic graph, meaning any migration file can be dependent
+ on any other arbitrary set of migration files, or none at
+ all. Through this open-ended system, migration files can be organized
+ into branches, multiple roots, and mergepoints, without restriction.
+ Commands are provided to produce new branches, roots, and merges of
+ branches automatically.
+* Provide a library of ALTER constructs that can be used by any SQLAlchemy
+ application. The DDL constructs build upon SQLAlchemy's own DDLElement base
+ and can be used standalone by any application or script.
+* At long last, bring SQLite and its inability to ALTER things into the fold,
+ but in such a way that SQLite's very special workflow needs are accommodated
+ in an explicit way that makes the most of a bad situation, through the
+ concept of a "batch" migration, where multiple changes to a table can
+ be batched together to form a series of instructions for a single, subsequent
+ "move-and-copy" workflow. You can even use "move-and-copy" workflow for
+ other databases, if you want to recreate a table in the background
+ on a busy system.
+
+Documentation and status of Alembic is at https://alembic.sqlalchemy.org/
+
+The SQLAlchemy Project
+======================
+
+Alembic is part of the `SQLAlchemy Project `_ and
+adheres to the same standards and conventions as the core project.
+
+Development / Bug reporting / Pull requests
+___________________________________________
+
+Please refer to the
+`SQLAlchemy Community Guide `_ for
+guidelines on coding and participating in this project.
+
+Code of Conduct
+_______________
+
+Above all, SQLAlchemy places great emphasis on polite, thoughtful, and
+constructive communication between users and developers.
+Please see our current Code of Conduct at
+`Code of Conduct `_.
+
+License
+=======
+
+Alembic is distributed under the `MIT license
+`_.
diff --git a/libs/alembic-1.18.4.dist-info/RECORD b/libs/alembic-1.18.4.dist-info/RECORD
new file mode 100644
index 0000000000..e515af6fb8
--- /dev/null
+++ b/libs/alembic-1.18.4.dist-info/RECORD
@@ -0,0 +1,104 @@
+../../bin/alembic,sha256=L1WsuXkAaKzUoMmUw815BjlcETDzheJhPsz_pk-A2uM,189
+alembic-1.18.4.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
+alembic-1.18.4.dist-info/METADATA,sha256=sPH3Zq5eEaNtbnI1os9Rvk7eBbFJSMPq13poNNaxvfs,7217
+alembic-1.18.4.dist-info/RECORD,,
+alembic-1.18.4.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
+alembic-1.18.4.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
+alembic-1.18.4.dist-info/entry_points.txt,sha256=aykM30soxwGN0pB7etLc1q0cHJbL9dy46RnK9VX4LLw,48
+alembic-1.18.4.dist-info/licenses/LICENSE,sha256=bmjZSgOg4-Mn3fPobR6-3BTuzjkiAiYY_CRqNilv0Mw,1059
+alembic-1.18.4.dist-info/top_level.txt,sha256=FwKWd5VsPFC8iQjpu1u9Cn-JnK3-V1RhUCmWqz1cl-s,8
+alembic/__init__.py,sha256=6ppwNUS6dfdFIm5uwZaaZ9lDZ7pIwkTNyQcbjY47V3I,93
+alembic/__main__.py,sha256=373m7-TBh72JqrSMYviGrxCHZo-cnweM8AGF8A22PmY,78
+alembic/autogenerate/__init__.py,sha256=ntmUTXhjLm4_zmqIwyVaECdpPDn6_u1yM9vYk6-553E,543
+alembic/autogenerate/api.py,sha256=8tVNDSHlqsBgj1IVLdqvZr_jlvz9kp3O5EKIL9biaZg,22781
+alembic/autogenerate/compare/__init__.py,sha256=kCvA0ZK0rTahNv9wlgyIB5DH2lFEhTRO4PFmoqcL9JE,1809
+alembic/autogenerate/compare/comments.py,sha256=agSrWsZhJ47i-E-EqiP3id2CXTTbP0muOKk1-9in9lg,3234
+alembic/autogenerate/compare/constraints.py,sha256=7sLSvUK9M2CbMRRQy5pveIXbjDLRDnfPx0Dvi_KXOf8,27906
+alembic/autogenerate/compare/schema.py,sha256=plQ7JJ1zJGlnajweSV8lAD9tDYPks5G40sliocTuXJA,1695
+alembic/autogenerate/compare/server_defaults.py,sha256=D--5EvEfyX0fSVkK6iLtRoer5sYK6xeNC2TIdu7klUk,10792
+alembic/autogenerate/compare/tables.py,sha256=47pAgVhbmXGLrm3dMK6hrNABxOAe_cGSQmPtCBwORVc,10611
+alembic/autogenerate/compare/types.py,sha256=75bOduz-dOiyLI065XD5sEP_JF9GPLkDAQ_y5B8lXF0,4005
+alembic/autogenerate/compare/util.py,sha256=K_GArJ2xQXZi6ftb8gkgZuIdVqvyep3E2ZXq8F3-jIU,9521
+alembic/autogenerate/render.py,sha256=ceQL8nk8m2kBtQq5gtxtDLR9iR0Sck8xG_61Oez-Sqs,37270
+alembic/autogenerate/rewriter.py,sha256=NIASSS-KaNKPmbm1k4pE45aawwjSh1Acf6eZrOwnUGM,7814
+alembic/command.py,sha256=7RzAwwXR31sOl0oVItyZl9B0j3TeR5dRyx9634lVsLM,25297
+alembic/config.py,sha256=VoCZV2cFZoF0Xa1OxHqsA-MKzuwBRaJSC7hxZ3-uWN4,34983
+alembic/context.py,sha256=hK1AJOQXJ29Bhn276GYcosxeG7pC5aZRT5E8c4bMJ4Q,195
+alembic/context.pyi,sha256=b_naI_W8dyiZRsL_n299a-LbqLZxKTAgDIXubRLVKlY,32531
+alembic/ddl/__init__.py,sha256=Df8fy4Vn_abP8B7q3x8gyFwEwnLw6hs2Ljt_bV3EZWE,152
+alembic/ddl/_autogen.py,sha256=Blv2RrHNyF4cE6znCQXNXG5T9aO-YmiwD4Fz-qfoaWA,9275
+alembic/ddl/base.py,sha256=dNhLIZnFMP7Cr8rE_e2Zb5skGgCMBOdca1PajXqZYhs,11977
+alembic/ddl/impl.py,sha256=IU3yHFVI3v0QHEwNL_LSN1PRpPF0n09NFFqRZkW86wE,31376
+alembic/ddl/mssql.py,sha256=dee0acwnxmTZXuYPqqlYaDiSbKS46zVH0WRULjX5Blg,17398
+alembic/ddl/mysql.py,sha256=2fvzGcdg4qqCJogGnzvQN636vUi9mF6IoQWLGevvF_A,18456
+alembic/ddl/oracle.py,sha256=669YlkcZihlXFbnXhH2krdrvDry8q5pcUGfoqkg_R6Y,6243
+alembic/ddl/postgresql.py,sha256=04M4OpZOCJJ3ipuHoVwlR1gI1sgRwOguRRVx_mFg8Uc,30417
+alembic/ddl/sqlite.py,sha256=TmzU3YaR3aw_0spSrA6kcUY8fyDfwsu4GkH5deYPEK8,8017
+alembic/environment.py,sha256=MM5lPayGT04H3aeng1H7GQ8HEAs3VGX5yy6mDLCPLT4,43
+alembic/migration.py,sha256=MV6Fju6rZtn2fTREKzXrCZM6aIBGII4OMZFix0X-GLs,41
+alembic/op.py,sha256=flHtcsVqOD-ZgZKK2pv-CJ5Cwh-KJ7puMUNXzishxLw,167
+alembic/op.pyi,sha256=ABBlNk4Eg7DR17knSKIjmvHQBNAmKh3aHQNHU8Oyw08,53347
+alembic/operations/__init__.py,sha256=e0KQSZAgLpTWvyvreB7DWg7RJV_MWSOPVDgCqsd2FzY,318
+alembic/operations/base.py,sha256=ubpv1HDol0g0nuLi0b8-uN7-HEVRZ6mq8arvK9EGo0g,78432
+alembic/operations/batch.py,sha256=hYOpzG2FK_8hk-rHNuLuFAA3-VXRSOnsTrpz2YlA61Q,26947
+alembic/operations/ops.py,sha256=ofbHkReZkZX2n9lXDaIPlrKe2U1mwgQpZNhEbuC4QrM,99325
+alembic/operations/schemaobj.py,sha256=Wp-bBe4a8lXPTvIHJttBY0ejtpVR5Jvtb2kI-U2PztQ,9468
+alembic/operations/toimpl.py,sha256=f8rH3jdob9XvEJr6CoWEkX6X1zgNB5qxdcEQugyhBvU,8466
+alembic/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
+alembic/runtime/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
+alembic/runtime/environment.py,sha256=1cR1v18sIKvOPZMlc4fHGU4J8r6Dec9h4o3WXkMmFKQ,42400
+alembic/runtime/migration.py,sha256=mR2Ee1h9Yy6OMFeDL4LOYorLYby2l2f899WGK_boECw,48427
+alembic/runtime/plugins.py,sha256=pWCDhMX8MvR8scXhiGSRNYNW7-ckEbOW2qK58xRFy1Q,5707
+alembic/script/__init__.py,sha256=lSj06O391Iy5avWAiq8SPs6N8RBgxkSPjP8wpXcNDGg,100
+alembic/script/base.py,sha256=OInSjbfcnUSjVCc5vVYY33UJ1Uo5xE5Huicp8P9VM1I,36698
+alembic/script/revision.py,sha256=SEePZPTMIyfjF73QAD0VIax9jc1dALkiLQZwTzwiyPw,62312
+alembic/script/write_hooks.py,sha256=KWH12250h_JcdBkGsLVo9JKYKpNcJxBUjwZ9r_r88Bc,5369
+alembic/templates/async/README,sha256=ISVtAOvqvKk_5ThM5ioJE-lMkvf9IbknFUFVU_vPma4,58
+alembic/templates/async/alembic.ini.mako,sha256=esbuCnpkyjntJC7k9NnYcCAzhrRQ8NVC4pWineiRk_w,5010
+alembic/templates/async/env.py,sha256=zbOCf3Y7w2lg92hxSwmG1MM_7y56i_oRH4AKp0pQBYo,2389
+alembic/templates/async/script.py.mako,sha256=04kgeBtNMa4cCnG8CfQcKt6P6rnloIfj8wy0u_DBydM,704
+alembic/templates/generic/README,sha256=MVlc9TYmr57RbhXET6QxgyCcwWP7w-vLkEsirENqiIQ,38
+alembic/templates/generic/alembic.ini.mako,sha256=2i2vPsGQSmE9XMiLz8tSBF_UIA8PJl0-fAvbRVmiK_w,5010
+alembic/templates/generic/env.py,sha256=TLRWOVW3Xpt_Tpf8JFzlnoPn_qoUu8UV77Y4o9XD6yI,2103
+alembic/templates/generic/script.py.mako,sha256=04kgeBtNMa4cCnG8CfQcKt6P6rnloIfj8wy0u_DBydM,704
+alembic/templates/multidb/README,sha256=dWLDhnBgphA4Nzb7sNlMfCS3_06YqVbHhz-9O5JNqyI,606
+alembic/templates/multidb/alembic.ini.mako,sha256=asVt3aJVwjuuw9bopfMofVvonO31coXBbV5DeMRN6cM,5336
+alembic/templates/multidb/env.py,sha256=6zNjnW8mXGUk7erTsAvrfhvqoczJ-gagjVq1Ypg2YIQ,4230
+alembic/templates/multidb/script.py.mako,sha256=ZbCXMkI5Wj2dwNKcxuVGkKZ7Iav93BNx_bM4zbGi3c8,1235
+alembic/templates/pyproject/README,sha256=dMhIiFoeM7EdeaOXBs3mVQ6zXACMyGXDb_UBB6sGRA0,60
+alembic/templates/pyproject/alembic.ini.mako,sha256=bQnEoydnLOUgg9vNbTOys4r5MaW8lmwYFXSrlfdEEkw,782
+alembic/templates/pyproject/env.py,sha256=TLRWOVW3Xpt_Tpf8JFzlnoPn_qoUu8UV77Y4o9XD6yI,2103
+alembic/templates/pyproject/pyproject.toml.mako,sha256=W6x_K-xLfEvyM8D4B3Fg0l20P1h6SPK33188pqRFroQ,3000
+alembic/templates/pyproject/script.py.mako,sha256=04kgeBtNMa4cCnG8CfQcKt6P6rnloIfj8wy0u_DBydM,704
+alembic/templates/pyproject_async/README,sha256=2Q5XcEouiqQ-TJssO9805LROkVUd0F6d74rTnuLrifA,45
+alembic/templates/pyproject_async/alembic.ini.mako,sha256=bQnEoydnLOUgg9vNbTOys4r5MaW8lmwYFXSrlfdEEkw,782
+alembic/templates/pyproject_async/env.py,sha256=zbOCf3Y7w2lg92hxSwmG1MM_7y56i_oRH4AKp0pQBYo,2389
+alembic/templates/pyproject_async/pyproject.toml.mako,sha256=W6x_K-xLfEvyM8D4B3Fg0l20P1h6SPK33188pqRFroQ,3000
+alembic/templates/pyproject_async/script.py.mako,sha256=04kgeBtNMa4cCnG8CfQcKt6P6rnloIfj8wy0u_DBydM,704
+alembic/testing/__init__.py,sha256=PTMhi_2PZ1T_3atQS2CIr0V4YRZzx_doKI-DxKdQS44,1297
+alembic/testing/assertions.py,sha256=VKXMEVWjuPAsYnNxP3WnUpXaFN3ytNFf9LI72OEJ074,5344
+alembic/testing/env.py,sha256=oQN56xXHtHfK8RD-8pH8yZ-uWcjpuNL1Mt5HNrzZyc0,12151
+alembic/testing/fixtures.py,sha256=meqm10rd1ynppW6tw1wcpDJJLyQezZ7FwKyqcrwIOok,11931
+alembic/testing/plugin/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
+alembic/testing/plugin/bootstrap.py,sha256=9C6wtjGrIVztZ928w27hsQE0KcjDLIUtUN3dvZKsMVk,50
+alembic/testing/requirements.py,sha256=OZSHd8I3zOb7288cZxUTebqxx8j0T6I8MekH15TyPvY,4566
+alembic/testing/schemacompare.py,sha256=N5UqSNCOJetIKC4vKhpYzQEpj08XkdgIoqBmEPQ3tlc,4838
+alembic/testing/suite/__init__.py,sha256=MvE7-hwbaVN1q3NM-ztGxORU9dnIelUCINKqNxewn7Y,288
+alembic/testing/suite/_autogen_fixtures.py,sha256=3nNTd8iDeVeSgpPIj8KAraNbU-PkJtxDb4X_TVsZ528,14200
+alembic/testing/suite/test_autogen_comments.py,sha256=aEGqKUDw4kHjnDk298aoGcQvXJWmZXcIX_2FxH4cJK8,6283
+alembic/testing/suite/test_autogen_computed.py,sha256=puJ0hBtLzNz8LiPGqDPS8vse6dUS9VCBpUdw-cOksZo,4554
+alembic/testing/suite/test_autogen_diffs.py,sha256=T4SR1n_kmcOKYhR4W1-dA0e5sddJ69DSVL2HW96kAkE,8394
+alembic/testing/suite/test_autogen_fks.py,sha256=wHKjD4Egf7IZlH0HYw-c8uti0jhJpOm5K42QMXf5tIw,32930
+alembic/testing/suite/test_autogen_identity.py,sha256=kcuqngG7qXAKPJDX4U8sRzPKHEJECHuZ0DtuaS6tVkk,5824
+alembic/testing/suite/test_environment.py,sha256=OwD-kpESdLoc4byBrGrXbZHvqtPbzhFCG4W9hJOJXPQ,11877
+alembic/testing/suite/test_op.py,sha256=2XQCdm_NmnPxHGuGj7hmxMzIhKxXNotUsKdACXzE1mM,1343
+alembic/testing/util.py,sha256=CQrcQDA8fs_7ME85z5ydb-Bt70soIIID-qNY1vbR2dg,3350
+alembic/testing/warnings.py,sha256=cDDWzvxNZE6x9dME2ACTXSv01G81JcIbE1GIE_s1kvg,831
+alembic/util/__init__.py,sha256=xNpZtajyTF4eVEbLj0Pcm2FbNkIZD_pCvKGKSPucTEs,1777
+alembic/util/compat.py,sha256=NytmcsMtK8WEEVwWc-ZWYlSOi55BtRlmJXjxnF3nsh8,3810
+alembic/util/editor.py,sha256=JIz6_BdgV8_oKtnheR6DZoB7qnrHrlRgWjx09AsTsUw,2546
+alembic/util/exc.py,sha256=SublpLmAeAW8JeEml-1YyhIjkSORTkZbvHVVJeoPymg,993
+alembic/util/langhelpers.py,sha256=GBbR01xNi1kmz8W37h0NzXl3hBC1SY7k7Bj-h5jVgps,13164
+alembic/util/messaging.py,sha256=3bEBoDy4EAXETXAvArlYjeMITXDTgPTu6ZoE3ytnzSw,3294
+alembic/util/pyfiles.py,sha256=QUZYc5kE3Z7nV64PblcRffzA7VfVaiFB2x3vtcG0_AE,4707
+alembic/util/sqla_compat.py,sha256=llgJVtOsO1c3euS9_peORZkM9QeSvQWa-1LNHqrzEM4,15246
diff --git a/libs/Flask_Cors-5.0.0.dist-info/REQUESTED b/libs/alembic-1.18.4.dist-info/REQUESTED
similarity index 100%
rename from libs/Flask_Cors-5.0.0.dist-info/REQUESTED
rename to libs/alembic-1.18.4.dist-info/REQUESTED
diff --git a/libs/alembic-1.18.4.dist-info/WHEEL b/libs/alembic-1.18.4.dist-info/WHEEL
new file mode 100644
index 0000000000..0885d05555
--- /dev/null
+++ b/libs/alembic-1.18.4.dist-info/WHEEL
@@ -0,0 +1,5 @@
+Wheel-Version: 1.0
+Generator: setuptools (80.10.2)
+Root-Is-Purelib: true
+Tag: py3-none-any
+
diff --git a/libs/alembic-1.14.0.dist-info/entry_points.txt b/libs/alembic-1.18.4.dist-info/entry_points.txt
similarity index 100%
rename from libs/alembic-1.14.0.dist-info/entry_points.txt
rename to libs/alembic-1.18.4.dist-info/entry_points.txt
diff --git a/libs/alembic-1.18.4.dist-info/licenses/LICENSE b/libs/alembic-1.18.4.dist-info/licenses/LICENSE
new file mode 100644
index 0000000000..b03e235f9a
--- /dev/null
+++ b/libs/alembic-1.18.4.dist-info/licenses/LICENSE
@@ -0,0 +1,19 @@
+Copyright 2009-2026 Michael Bayer.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+of the Software, and to permit persons to whom the Software is furnished to do
+so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/libs/alembic-1.14.0.dist-info/top_level.txt b/libs/alembic-1.18.4.dist-info/top_level.txt
similarity index 100%
rename from libs/alembic-1.14.0.dist-info/top_level.txt
rename to libs/alembic-1.18.4.dist-info/top_level.txt
diff --git a/libs/alembic/__init__.py b/libs/alembic/__init__.py
index 637b2d4e14..059b6b1588 100644
--- a/libs/alembic/__init__.py
+++ b/libs/alembic/__init__.py
@@ -1,4 +1,6 @@
from . import context
from . import op
+from .runtime import plugins
-__version__ = "1.14.0"
+
+__version__ = "1.18.4"
diff --git a/libs/alembic/autogenerate/api.py b/libs/alembic/autogenerate/api.py
index 4c03916288..b2e3faef78 100644
--- a/libs/alembic/autogenerate/api.py
+++ b/libs/alembic/autogenerate/api.py
@@ -1,6 +1,7 @@
from __future__ import annotations
import contextlib
+import logging
from typing import Any
from typing import Dict
from typing import Iterator
@@ -17,11 +18,9 @@
from . import render
from .. import util
from ..operations import ops
+from ..runtime.plugins import Plugin
from ..util import sqla_compat
-"""Provide the 'autogenerate' feature which can produce migration operations
-automatically."""
-
if TYPE_CHECKING:
from sqlalchemy.engine import Connection
from sqlalchemy.engine import Dialect
@@ -42,6 +41,10 @@
from ..script.base import Script
from ..script.base import ScriptDirectory
from ..script.revision import _GetRevArg
+ from ..util import PriorityDispatcher
+
+
+log = logging.getLogger(__name__)
def compare_metadata(context: MigrationContext, metadata: MetaData) -> Any:
@@ -277,7 +280,7 @@ class AutogenContext:
"""Maintains configuration and state that's specific to an
autogenerate operation."""
- metadata: Optional[MetaData] = None
+ metadata: Union[MetaData, Sequence[MetaData], None] = None
"""The :class:`~sqlalchemy.schema.MetaData` object
representing the destination.
@@ -304,7 +307,7 @@ class AutogenContext:
"""
- dialect: Optional[Dialect] = None
+ dialect: Dialect
"""The :class:`~sqlalchemy.engine.Dialect` object currently in use.
This is normally obtained from the
@@ -326,13 +329,15 @@ class AutogenContext:
"""
- migration_context: MigrationContext = None # type: ignore[assignment]
+ migration_context: MigrationContext
"""The :class:`.MigrationContext` established by the ``env.py`` script."""
+ comparators: PriorityDispatcher
+
def __init__(
self,
migration_context: MigrationContext,
- metadata: Optional[MetaData] = None,
+ metadata: Union[MetaData, Sequence[MetaData], None] = None,
opts: Optional[Dict[str, Any]] = None,
autogenerate: bool = True,
) -> None:
@@ -346,6 +351,19 @@ def __init__(
"the database for schema information"
)
+ # branch off from the "global" comparators. This collection
+ # is empty in Alembic except that it is populated by third party
+ # extensions that don't use the plugin system. so we will build
+ # off of whatever is in there.
+ if autogenerate:
+ self.comparators = compare.comparators.branch()
+ Plugin.populate_autogenerate_priority_dispatch(
+ self.comparators,
+ include_plugins=migration_context.opts.get(
+ "autogenerate_plugins", ["alembic.autogenerate.*"]
+ ),
+ )
+
if opts is None:
opts = migration_context.opts
@@ -380,9 +398,8 @@ def __init__(
self._name_filters = name_filters
self.migration_context = migration_context
- if self.migration_context is not None:
- self.connection = self.migration_context.bind
- self.dialect = self.migration_context.dialect
+ self.connection = self.migration_context.bind
+ self.dialect = self.migration_context.dialect
self.imports = set()
self.opts: Dict[str, Any] = opts
diff --git a/libs/alembic/autogenerate/compare.py b/libs/alembic/autogenerate/compare.py
deleted file mode 100644
index 0d98519643..0000000000
--- a/libs/alembic/autogenerate/compare.py
+++ /dev/null
@@ -1,1329 +0,0 @@
-# mypy: allow-untyped-defs, allow-incomplete-defs, allow-untyped-calls
-# mypy: no-warn-return-any, allow-any-generics
-
-from __future__ import annotations
-
-import contextlib
-import logging
-import re
-from typing import Any
-from typing import cast
-from typing import Dict
-from typing import Iterator
-from typing import Mapping
-from typing import Optional
-from typing import Set
-from typing import Tuple
-from typing import TYPE_CHECKING
-from typing import TypeVar
-from typing import Union
-
-from sqlalchemy import event
-from sqlalchemy import inspect
-from sqlalchemy import schema as sa_schema
-from sqlalchemy import text
-from sqlalchemy import types as sqltypes
-from sqlalchemy.sql import expression
-from sqlalchemy.sql.schema import ForeignKeyConstraint
-from sqlalchemy.sql.schema import Index
-from sqlalchemy.sql.schema import UniqueConstraint
-from sqlalchemy.util import OrderedSet
-
-from .. import util
-from ..ddl._autogen import is_index_sig
-from ..ddl._autogen import is_uq_sig
-from ..operations import ops
-from ..util import sqla_compat
-
-if TYPE_CHECKING:
- from typing import Literal
-
- from sqlalchemy.engine.reflection import Inspector
- from sqlalchemy.sql.elements import quoted_name
- from sqlalchemy.sql.elements import TextClause
- from sqlalchemy.sql.schema import Column
- from sqlalchemy.sql.schema import Table
-
- from alembic.autogenerate.api import AutogenContext
- from alembic.ddl.impl import DefaultImpl
- from alembic.operations.ops import AlterColumnOp
- from alembic.operations.ops import MigrationScript
- from alembic.operations.ops import ModifyTableOps
- from alembic.operations.ops import UpgradeOps
- from ..ddl._autogen import _constraint_sig
-
-
-log = logging.getLogger(__name__)
-
-
-def _populate_migration_script(
- autogen_context: AutogenContext, migration_script: MigrationScript
-) -> None:
- upgrade_ops = migration_script.upgrade_ops_list[-1]
- downgrade_ops = migration_script.downgrade_ops_list[-1]
-
- _produce_net_changes(autogen_context, upgrade_ops)
- upgrade_ops.reverse_into(downgrade_ops)
-
-
-comparators = util.Dispatcher(uselist=True)
-
-
-def _produce_net_changes(
- autogen_context: AutogenContext, upgrade_ops: UpgradeOps
-) -> None:
- connection = autogen_context.connection
- assert connection is not None
- include_schemas = autogen_context.opts.get("include_schemas", False)
-
- inspector: Inspector = inspect(connection)
-
- default_schema = connection.dialect.default_schema_name
- schemas: Set[Optional[str]]
- if include_schemas:
- schemas = set(inspector.get_schema_names())
- # replace default schema name with None
- schemas.discard("information_schema")
- # replace the "default" schema with None
- schemas.discard(default_schema)
- schemas.add(None)
- else:
- schemas = {None}
-
- schemas = {
- s for s in schemas if autogen_context.run_name_filters(s, "schema", {})
- }
-
- assert autogen_context.dialect is not None
- comparators.dispatch("schema", autogen_context.dialect.name)(
- autogen_context, upgrade_ops, schemas
- )
-
-
-@comparators.dispatch_for("schema")
-def _autogen_for_tables(
- autogen_context: AutogenContext,
- upgrade_ops: UpgradeOps,
- schemas: Union[Set[None], Set[Optional[str]]],
-) -> None:
- inspector = autogen_context.inspector
-
- conn_table_names: Set[Tuple[Optional[str], str]] = set()
-
- version_table_schema = (
- autogen_context.migration_context.version_table_schema
- )
- version_table = autogen_context.migration_context.version_table
-
- for schema_name in schemas:
- tables = set(inspector.get_table_names(schema=schema_name))
- if schema_name == version_table_schema:
- tables = tables.difference(
- [autogen_context.migration_context.version_table]
- )
-
- conn_table_names.update(
- (schema_name, tname)
- for tname in tables
- if autogen_context.run_name_filters(
- tname, "table", {"schema_name": schema_name}
- )
- )
-
- metadata_table_names = OrderedSet(
- [(table.schema, table.name) for table in autogen_context.sorted_tables]
- ).difference([(version_table_schema, version_table)])
-
- _compare_tables(
- conn_table_names,
- metadata_table_names,
- inspector,
- upgrade_ops,
- autogen_context,
- )
-
-
-def _compare_tables(
- conn_table_names: set,
- metadata_table_names: set,
- inspector: Inspector,
- upgrade_ops: UpgradeOps,
- autogen_context: AutogenContext,
-) -> None:
- default_schema = inspector.bind.dialect.default_schema_name
-
- # tables coming from the connection will not have "schema"
- # set if it matches default_schema_name; so we need a list
- # of table names from local metadata that also have "None" if schema
- # == default_schema_name. Most setups will be like this anyway but
- # some are not (see #170)
- metadata_table_names_no_dflt_schema = OrderedSet(
- [
- (schema if schema != default_schema else None, tname)
- for schema, tname in metadata_table_names
- ]
- )
-
- # to adjust for the MetaData collection storing the tables either
- # as "schemaname.tablename" or just "tablename", create a new lookup
- # which will match the "non-default-schema" keys to the Table object.
- tname_to_table = {
- no_dflt_schema: autogen_context.table_key_to_table[
- sa_schema._get_table_key(tname, schema)
- ]
- for no_dflt_schema, (schema, tname) in zip(
- metadata_table_names_no_dflt_schema, metadata_table_names
- )
- }
- metadata_table_names = metadata_table_names_no_dflt_schema
-
- for s, tname in metadata_table_names.difference(conn_table_names):
- name = "%s.%s" % (s, tname) if s else tname
- metadata_table = tname_to_table[(s, tname)]
- if autogen_context.run_object_filters(
- metadata_table, tname, "table", False, None
- ):
- upgrade_ops.ops.append(
- ops.CreateTableOp.from_table(metadata_table)
- )
- log.info("Detected added table %r", name)
- modify_table_ops = ops.ModifyTableOps(tname, [], schema=s)
-
- comparators.dispatch("table")(
- autogen_context,
- modify_table_ops,
- s,
- tname,
- None,
- metadata_table,
- )
- if not modify_table_ops.is_empty():
- upgrade_ops.ops.append(modify_table_ops)
-
- removal_metadata = sa_schema.MetaData()
- for s, tname in conn_table_names.difference(metadata_table_names):
- name = sa_schema._get_table_key(tname, s)
- exists = name in removal_metadata.tables
- t = sa_schema.Table(tname, removal_metadata, schema=s)
-
- if not exists:
- event.listen(
- t,
- "column_reflect",
- # fmt: off
- autogen_context.migration_context.impl.
- _compat_autogen_column_reflect
- (inspector),
- # fmt: on
- )
- sqla_compat._reflect_table(inspector, t)
- if autogen_context.run_object_filters(t, tname, "table", True, None):
- modify_table_ops = ops.ModifyTableOps(tname, [], schema=s)
-
- comparators.dispatch("table")(
- autogen_context, modify_table_ops, s, tname, t, None
- )
- if not modify_table_ops.is_empty():
- upgrade_ops.ops.append(modify_table_ops)
-
- upgrade_ops.ops.append(ops.DropTableOp.from_table(t))
- log.info("Detected removed table %r", name)
-
- existing_tables = conn_table_names.intersection(metadata_table_names)
-
- existing_metadata = sa_schema.MetaData()
- conn_column_info = {}
- for s, tname in existing_tables:
- name = sa_schema._get_table_key(tname, s)
- exists = name in existing_metadata.tables
- t = sa_schema.Table(tname, existing_metadata, schema=s)
- if not exists:
- event.listen(
- t,
- "column_reflect",
- # fmt: off
- autogen_context.migration_context.impl.
- _compat_autogen_column_reflect(inspector),
- # fmt: on
- )
- sqla_compat._reflect_table(inspector, t)
- conn_column_info[(s, tname)] = t
-
- for s, tname in sorted(existing_tables, key=lambda x: (x[0] or "", x[1])):
- s = s or None
- name = "%s.%s" % (s, tname) if s else tname
- metadata_table = tname_to_table[(s, tname)]
- conn_table = existing_metadata.tables[name]
-
- if autogen_context.run_object_filters(
- metadata_table, tname, "table", False, conn_table
- ):
- modify_table_ops = ops.ModifyTableOps(tname, [], schema=s)
- with _compare_columns(
- s,
- tname,
- conn_table,
- metadata_table,
- modify_table_ops,
- autogen_context,
- inspector,
- ):
- comparators.dispatch("table")(
- autogen_context,
- modify_table_ops,
- s,
- tname,
- conn_table,
- metadata_table,
- )
-
- if not modify_table_ops.is_empty():
- upgrade_ops.ops.append(modify_table_ops)
-
-
-_IndexColumnSortingOps: Mapping[str, Any] = util.immutabledict(
- {
- "asc": expression.asc,
- "desc": expression.desc,
- "nulls_first": expression.nullsfirst,
- "nulls_last": expression.nullslast,
- "nullsfirst": expression.nullsfirst, # 1_3 name
- "nullslast": expression.nullslast, # 1_3 name
- }
-)
-
-
-def _make_index(
- impl: DefaultImpl, params: Dict[str, Any], conn_table: Table
-) -> Optional[Index]:
- exprs: list[Union[Column[Any], TextClause]] = []
- sorting = params.get("column_sorting")
-
- for num, col_name in enumerate(params["column_names"]):
- item: Union[Column[Any], TextClause]
- if col_name is None:
- assert "expressions" in params
- name = params["expressions"][num]
- item = text(name)
- else:
- name = col_name
- item = conn_table.c[col_name]
- if sorting and name in sorting:
- for operator in sorting[name]:
- if operator in _IndexColumnSortingOps:
- item = _IndexColumnSortingOps[operator](item)
- exprs.append(item)
- ix = sa_schema.Index(
- params["name"],
- *exprs,
- unique=params["unique"],
- _table=conn_table,
- **impl.adjust_reflected_dialect_options(params, "index"),
- )
- if "duplicates_constraint" in params:
- ix.info["duplicates_constraint"] = params["duplicates_constraint"]
- return ix
-
-
-def _make_unique_constraint(
- impl: DefaultImpl, params: Dict[str, Any], conn_table: Table
-) -> UniqueConstraint:
- uq = sa_schema.UniqueConstraint(
- *[conn_table.c[cname] for cname in params["column_names"]],
- name=params["name"],
- **impl.adjust_reflected_dialect_options(params, "unique_constraint"),
- )
- if "duplicates_index" in params:
- uq.info["duplicates_index"] = params["duplicates_index"]
-
- return uq
-
-
-def _make_foreign_key(
- params: Dict[str, Any], conn_table: Table
-) -> ForeignKeyConstraint:
- tname = params["referred_table"]
- if params["referred_schema"]:
- tname = "%s.%s" % (params["referred_schema"], tname)
-
- options = params.get("options", {})
-
- const = sa_schema.ForeignKeyConstraint(
- [conn_table.c[cname] for cname in params["constrained_columns"]],
- ["%s.%s" % (tname, n) for n in params["referred_columns"]],
- onupdate=options.get("onupdate"),
- ondelete=options.get("ondelete"),
- deferrable=options.get("deferrable"),
- initially=options.get("initially"),
- name=params["name"],
- )
- # needed by 0.7
- conn_table.append_constraint(const)
- return const
-
-
-@contextlib.contextmanager
-def _compare_columns(
- schema: Optional[str],
- tname: Union[quoted_name, str],
- conn_table: Table,
- metadata_table: Table,
- modify_table_ops: ModifyTableOps,
- autogen_context: AutogenContext,
- inspector: Inspector,
-) -> Iterator[None]:
- name = "%s.%s" % (schema, tname) if schema else tname
- metadata_col_names = OrderedSet(
- c.name for c in metadata_table.c if not c.system
- )
- metadata_cols_by_name = {
- c.name: c for c in metadata_table.c if not c.system
- }
-
- conn_col_names = {
- c.name: c
- for c in conn_table.c
- if autogen_context.run_name_filters(
- c.name, "column", {"table_name": tname, "schema_name": schema}
- )
- }
-
- for cname in metadata_col_names.difference(conn_col_names):
- if autogen_context.run_object_filters(
- metadata_cols_by_name[cname], cname, "column", False, None
- ):
- modify_table_ops.ops.append(
- ops.AddColumnOp.from_column_and_tablename(
- schema, tname, metadata_cols_by_name[cname]
- )
- )
- log.info("Detected added column '%s.%s'", name, cname)
-
- for colname in metadata_col_names.intersection(conn_col_names):
- metadata_col = metadata_cols_by_name[colname]
- conn_col = conn_table.c[colname]
- if not autogen_context.run_object_filters(
- metadata_col, colname, "column", False, conn_col
- ):
- continue
- alter_column_op = ops.AlterColumnOp(tname, colname, schema=schema)
-
- comparators.dispatch("column")(
- autogen_context,
- alter_column_op,
- schema,
- tname,
- colname,
- conn_col,
- metadata_col,
- )
-
- if alter_column_op.has_changes():
- modify_table_ops.ops.append(alter_column_op)
-
- yield
-
- for cname in set(conn_col_names).difference(metadata_col_names):
- if autogen_context.run_object_filters(
- conn_table.c[cname], cname, "column", True, None
- ):
- modify_table_ops.ops.append(
- ops.DropColumnOp.from_column_and_tablename(
- schema, tname, conn_table.c[cname]
- )
- )
- log.info("Detected removed column '%s.%s'", name, cname)
-
-
-_C = TypeVar("_C", bound=Union[UniqueConstraint, ForeignKeyConstraint, Index])
-
-
-@comparators.dispatch_for("table")
-def _compare_indexes_and_uniques(
- autogen_context: AutogenContext,
- modify_ops: ModifyTableOps,
- schema: Optional[str],
- tname: Union[quoted_name, str],
- conn_table: Optional[Table],
- metadata_table: Optional[Table],
-) -> None:
- inspector = autogen_context.inspector
- is_create_table = conn_table is None
- is_drop_table = metadata_table is None
- impl = autogen_context.migration_context.impl
-
- # 1a. get raw indexes and unique constraints from metadata ...
- if metadata_table is not None:
- metadata_unique_constraints = {
- uq
- for uq in metadata_table.constraints
- if isinstance(uq, sa_schema.UniqueConstraint)
- }
- metadata_indexes = set(metadata_table.indexes)
- else:
- metadata_unique_constraints = set()
- metadata_indexes = set()
-
- conn_uniques = conn_indexes = frozenset() # type:ignore[var-annotated]
-
- supports_unique_constraints = False
-
- unique_constraints_duplicate_unique_indexes = False
-
- if conn_table is not None:
- # 1b. ... and from connection, if the table exists
- try:
- conn_uniques = inspector.get_unique_constraints( # type:ignore[assignment] # noqa
- tname, schema=schema
- )
- supports_unique_constraints = True
- except NotImplementedError:
- pass
- except TypeError:
- # number of arguments is off for the base
- # method in SQLAlchemy due to the cache decorator
- # not being present
- pass
- else:
- conn_uniques = [ # type:ignore[assignment]
- uq
- for uq in conn_uniques
- if autogen_context.run_name_filters(
- uq["name"],
- "unique_constraint",
- {"table_name": tname, "schema_name": schema},
- )
- ]
- for uq in conn_uniques:
- if uq.get("duplicates_index"):
- unique_constraints_duplicate_unique_indexes = True
- try:
- conn_indexes = inspector.get_indexes( # type:ignore[assignment]
- tname, schema=schema
- )
- except NotImplementedError:
- pass
- else:
- conn_indexes = [ # type:ignore[assignment]
- ix
- for ix in conn_indexes
- if autogen_context.run_name_filters(
- ix["name"],
- "index",
- {"table_name": tname, "schema_name": schema},
- )
- ]
-
- # 2. convert conn-level objects from raw inspector records
- # into schema objects
- if is_drop_table:
- # for DROP TABLE uniques are inline, don't need them
- conn_uniques = set() # type:ignore[assignment]
- else:
- conn_uniques = { # type:ignore[assignment]
- _make_unique_constraint(impl, uq_def, conn_table)
- for uq_def in conn_uniques
- }
-
- conn_indexes = { # type:ignore[assignment]
- index
- for index in (
- _make_index(impl, ix, conn_table) for ix in conn_indexes
- )
- if index is not None
- }
-
- # 2a. if the dialect dupes unique indexes as unique constraints
- # (mysql and oracle), correct for that
-
- if unique_constraints_duplicate_unique_indexes:
- _correct_for_uq_duplicates_uix(
- conn_uniques,
- conn_indexes,
- metadata_unique_constraints,
- metadata_indexes,
- autogen_context.dialect,
- impl,
- )
-
- # 3. give the dialect a chance to omit indexes and constraints that
- # we know are either added implicitly by the DB or that the DB
- # can't accurately report on
- impl.correct_for_autogen_constraints(
- conn_uniques, # type: ignore[arg-type]
- conn_indexes, # type: ignore[arg-type]
- metadata_unique_constraints,
- metadata_indexes,
- )
-
- # 4. organize the constraints into "signature" collections, the
- # _constraint_sig() objects provide a consistent facade over both
- # Index and UniqueConstraint so we can easily work with them
- # interchangeably
- metadata_unique_constraints_sig = {
- impl._create_metadata_constraint_sig(uq)
- for uq in metadata_unique_constraints
- }
-
- metadata_indexes_sig = {
- impl._create_metadata_constraint_sig(ix) for ix in metadata_indexes
- }
-
- conn_unique_constraints = {
- impl._create_reflected_constraint_sig(uq) for uq in conn_uniques
- }
-
- conn_indexes_sig = {
- impl._create_reflected_constraint_sig(ix) for ix in conn_indexes
- }
-
- # 5. index things by name, for those objects that have names
- metadata_names = {
- cast(str, c.md_name_to_sql_name(autogen_context)): c
- for c in metadata_unique_constraints_sig.union(metadata_indexes_sig)
- if c.is_named
- }
-
- conn_uniques_by_name: Dict[sqla_compat._ConstraintName, _constraint_sig]
- conn_indexes_by_name: Dict[sqla_compat._ConstraintName, _constraint_sig]
-
- conn_uniques_by_name = {c.name: c for c in conn_unique_constraints}
- conn_indexes_by_name = {c.name: c for c in conn_indexes_sig}
- conn_names = {
- c.name: c
- for c in conn_unique_constraints.union(conn_indexes_sig)
- if sqla_compat.constraint_name_string(c.name)
- }
-
- doubled_constraints = {
- name: (conn_uniques_by_name[name], conn_indexes_by_name[name])
- for name in set(conn_uniques_by_name).intersection(
- conn_indexes_by_name
- )
- }
-
- # 6. index things by "column signature", to help with unnamed unique
- # constraints.
- conn_uniques_by_sig = {uq.unnamed: uq for uq in conn_unique_constraints}
- metadata_uniques_by_sig = {
- uq.unnamed: uq for uq in metadata_unique_constraints_sig
- }
- unnamed_metadata_uniques = {
- uq.unnamed: uq
- for uq in metadata_unique_constraints_sig
- if not sqla_compat._constraint_is_named(
- uq.const, autogen_context.dialect
- )
- }
-
- # assumptions:
- # 1. a unique constraint or an index from the connection *always*
- # has a name.
- # 2. an index on the metadata side *always* has a name.
- # 3. a unique constraint on the metadata side *might* have a name.
- # 4. The backend may double up indexes as unique constraints and
- # vice versa (e.g. MySQL, Postgresql)
-
- def obj_added(obj: _constraint_sig):
- if is_index_sig(obj):
- if autogen_context.run_object_filters(
- obj.const, obj.name, "index", False, None
- ):
- modify_ops.ops.append(ops.CreateIndexOp.from_index(obj.const))
- log.info(
- "Detected added index '%r' on '%s'",
- obj.name,
- obj.column_names,
- )
- elif is_uq_sig(obj):
- if not supports_unique_constraints:
- # can't report unique indexes as added if we don't
- # detect them
- return
- if is_create_table or is_drop_table:
- # unique constraints are created inline with table defs
- return
- if autogen_context.run_object_filters(
- obj.const, obj.name, "unique_constraint", False, None
- ):
- modify_ops.ops.append(
- ops.AddConstraintOp.from_constraint(obj.const)
- )
- log.info(
- "Detected added unique constraint %r on '%s'",
- obj.name,
- obj.column_names,
- )
- else:
- assert False
-
- def obj_removed(obj: _constraint_sig):
- if is_index_sig(obj):
- if obj.is_unique and not supports_unique_constraints:
- # many databases double up unique constraints
- # as unique indexes. without that list we can't
- # be sure what we're doing here
- return
-
- if autogen_context.run_object_filters(
- obj.const, obj.name, "index", True, None
- ):
- modify_ops.ops.append(ops.DropIndexOp.from_index(obj.const))
- log.info("Detected removed index %r on %r", obj.name, tname)
- elif is_uq_sig(obj):
- if is_create_table or is_drop_table:
- # if the whole table is being dropped, we don't need to
- # consider unique constraint separately
- return
- if autogen_context.run_object_filters(
- obj.const, obj.name, "unique_constraint", True, None
- ):
- modify_ops.ops.append(
- ops.DropConstraintOp.from_constraint(obj.const)
- )
- log.info(
- "Detected removed unique constraint %r on %r",
- obj.name,
- tname,
- )
- else:
- assert False
-
- def obj_changed(
- old: _constraint_sig,
- new: _constraint_sig,
- msg: str,
- ):
- if is_index_sig(old):
- assert is_index_sig(new)
-
- if autogen_context.run_object_filters(
- new.const, new.name, "index", False, old.const
- ):
- log.info(
- "Detected changed index %r on %r: %s", old.name, tname, msg
- )
- modify_ops.ops.append(ops.DropIndexOp.from_index(old.const))
- modify_ops.ops.append(ops.CreateIndexOp.from_index(new.const))
- elif is_uq_sig(old):
- assert is_uq_sig(new)
-
- if autogen_context.run_object_filters(
- new.const, new.name, "unique_constraint", False, old.const
- ):
- log.info(
- "Detected changed unique constraint %r on %r: %s",
- old.name,
- tname,
- msg,
- )
- modify_ops.ops.append(
- ops.DropConstraintOp.from_constraint(old.const)
- )
- modify_ops.ops.append(
- ops.AddConstraintOp.from_constraint(new.const)
- )
- else:
- assert False
-
- for removed_name in sorted(set(conn_names).difference(metadata_names)):
- conn_obj = conn_names[removed_name]
- if (
- is_uq_sig(conn_obj)
- and conn_obj.unnamed in unnamed_metadata_uniques
- ):
- continue
- elif removed_name in doubled_constraints:
- conn_uq, conn_idx = doubled_constraints[removed_name]
- if (
- all(
- conn_idx.unnamed != meta_idx.unnamed
- for meta_idx in metadata_indexes_sig
- )
- and conn_uq.unnamed not in metadata_uniques_by_sig
- ):
- obj_removed(conn_uq)
- obj_removed(conn_idx)
- else:
- obj_removed(conn_obj)
-
- for existing_name in sorted(set(metadata_names).intersection(conn_names)):
- metadata_obj = metadata_names[existing_name]
-
- if existing_name in doubled_constraints:
- conn_uq, conn_idx = doubled_constraints[existing_name]
- if is_index_sig(metadata_obj):
- conn_obj = conn_idx
- else:
- conn_obj = conn_uq
- else:
- conn_obj = conn_names[existing_name]
-
- if type(conn_obj) != type(metadata_obj):
- obj_removed(conn_obj)
- obj_added(metadata_obj)
- else:
- comparison = metadata_obj.compare_to_reflected(conn_obj)
-
- if comparison.is_different:
- # constraint are different
- obj_changed(conn_obj, metadata_obj, comparison.message)
- elif comparison.is_skip:
- # constraint cannot be compared, skip them
- thing = (
- "index" if is_index_sig(conn_obj) else "unique constraint"
- )
- log.info(
- "Cannot compare %s %r, assuming equal and skipping. %s",
- thing,
- conn_obj.name,
- comparison.message,
- )
- else:
- # constraint are equal
- assert comparison.is_equal
-
- for added_name in sorted(set(metadata_names).difference(conn_names)):
- obj = metadata_names[added_name]
- obj_added(obj)
-
- for uq_sig in unnamed_metadata_uniques:
- if uq_sig not in conn_uniques_by_sig:
- obj_added(unnamed_metadata_uniques[uq_sig])
-
-
-def _correct_for_uq_duplicates_uix(
- conn_unique_constraints,
- conn_indexes,
- metadata_unique_constraints,
- metadata_indexes,
- dialect,
- impl,
-):
- # dedupe unique indexes vs. constraints, since MySQL / Oracle
- # doesn't really have unique constraints as a separate construct.
- # but look in the metadata and try to maintain constructs
- # that already seem to be defined one way or the other
- # on that side. This logic was formerly local to MySQL dialect,
- # generalized to Oracle and others. See #276
-
- # resolve final rendered name for unique constraints defined in the
- # metadata. this includes truncation of long names. naming convention
- # names currently should already be set as cons.name, however leave this
- # to the sqla_compat to decide.
- metadata_cons_names = [
- (sqla_compat._get_constraint_final_name(cons, dialect), cons)
- for cons in metadata_unique_constraints
- ]
-
- metadata_uq_names = {
- name for name, cons in metadata_cons_names if name is not None
- }
-
- unnamed_metadata_uqs = {
- impl._create_metadata_constraint_sig(cons).unnamed
- for name, cons in metadata_cons_names
- if name is None
- }
-
- metadata_ix_names = {
- sqla_compat._get_constraint_final_name(cons, dialect)
- for cons in metadata_indexes
- if cons.unique
- }
-
- # for reflection side, names are in their final database form
- # already since they're from the database
- conn_ix_names = {cons.name: cons for cons in conn_indexes if cons.unique}
-
- uqs_dupe_indexes = {
- cons.name: cons
- for cons in conn_unique_constraints
- if cons.info["duplicates_index"]
- }
-
- for overlap in uqs_dupe_indexes:
- if overlap not in metadata_uq_names:
- if (
- impl._create_reflected_constraint_sig(
- uqs_dupe_indexes[overlap]
- ).unnamed
- not in unnamed_metadata_uqs
- ):
- conn_unique_constraints.discard(uqs_dupe_indexes[overlap])
- elif overlap not in metadata_ix_names:
- conn_indexes.discard(conn_ix_names[overlap])
-
-
-@comparators.dispatch_for("column")
-def _compare_nullable(
- autogen_context: AutogenContext,
- alter_column_op: AlterColumnOp,
- schema: Optional[str],
- tname: Union[quoted_name, str],
- cname: Union[quoted_name, str],
- conn_col: Column[Any],
- metadata_col: Column[Any],
-) -> None:
- metadata_col_nullable = metadata_col.nullable
- conn_col_nullable = conn_col.nullable
- alter_column_op.existing_nullable = conn_col_nullable
-
- if conn_col_nullable is not metadata_col_nullable:
- if (
- sqla_compat._server_default_is_computed(
- metadata_col.server_default, conn_col.server_default
- )
- and sqla_compat._nullability_might_be_unset(metadata_col)
- or (
- sqla_compat._server_default_is_identity(
- metadata_col.server_default, conn_col.server_default
- )
- )
- ):
- log.info(
- "Ignoring nullable change on identity column '%s.%s'",
- tname,
- cname,
- )
- else:
- alter_column_op.modify_nullable = metadata_col_nullable
- log.info(
- "Detected %s on column '%s.%s'",
- "NULL" if metadata_col_nullable else "NOT NULL",
- tname,
- cname,
- )
-
-
-@comparators.dispatch_for("column")
-def _setup_autoincrement(
- autogen_context: AutogenContext,
- alter_column_op: AlterColumnOp,
- schema: Optional[str],
- tname: Union[quoted_name, str],
- cname: quoted_name,
- conn_col: Column[Any],
- metadata_col: Column[Any],
-) -> None:
- if metadata_col.table._autoincrement_column is metadata_col:
- alter_column_op.kw["autoincrement"] = True
- elif metadata_col.autoincrement is True:
- alter_column_op.kw["autoincrement"] = True
- elif metadata_col.autoincrement is False:
- alter_column_op.kw["autoincrement"] = False
-
-
-@comparators.dispatch_for("column")
-def _compare_type(
- autogen_context: AutogenContext,
- alter_column_op: AlterColumnOp,
- schema: Optional[str],
- tname: Union[quoted_name, str],
- cname: Union[quoted_name, str],
- conn_col: Column[Any],
- metadata_col: Column[Any],
-) -> None:
- conn_type = conn_col.type
- alter_column_op.existing_type = conn_type
- metadata_type = metadata_col.type
- if conn_type._type_affinity is sqltypes.NullType:
- log.info(
- "Couldn't determine database type " "for column '%s.%s'",
- tname,
- cname,
- )
- return
- if metadata_type._type_affinity is sqltypes.NullType:
- log.info(
- "Column '%s.%s' has no type within " "the model; can't compare",
- tname,
- cname,
- )
- return
-
- isdiff = autogen_context.migration_context._compare_type(
- conn_col, metadata_col
- )
-
- if isdiff:
- alter_column_op.modify_type = metadata_type
- log.info(
- "Detected type change from %r to %r on '%s.%s'",
- conn_type,
- metadata_type,
- tname,
- cname,
- )
-
-
-def _render_server_default_for_compare(
- metadata_default: Optional[Any], autogen_context: AutogenContext
-) -> Optional[str]:
- if isinstance(metadata_default, sa_schema.DefaultClause):
- if isinstance(metadata_default.arg, str):
- metadata_default = metadata_default.arg
- else:
- metadata_default = str(
- metadata_default.arg.compile(
- dialect=autogen_context.dialect,
- compile_kwargs={"literal_binds": True},
- )
- )
- if isinstance(metadata_default, str):
- return metadata_default
- else:
- return None
-
-
-def _normalize_computed_default(sqltext: str) -> str:
- """we want to warn if a computed sql expression has changed. however
- we don't want false positives and the warning is not that critical.
- so filter out most forms of variability from the SQL text.
-
- """
-
- return re.sub(r"[ \(\)'\"`\[\]\t\r\n]", "", sqltext).lower()
-
-
-def _compare_computed_default(
- autogen_context: AutogenContext,
- alter_column_op: AlterColumnOp,
- schema: Optional[str],
- tname: str,
- cname: str,
- conn_col: Column[Any],
- metadata_col: Column[Any],
-) -> None:
- rendered_metadata_default = str(
- cast(sa_schema.Computed, metadata_col.server_default).sqltext.compile(
- dialect=autogen_context.dialect,
- compile_kwargs={"literal_binds": True},
- )
- )
-
- # since we cannot change computed columns, we do only a crude comparison
- # here where we try to eliminate syntactical differences in order to
- # get a minimal comparison just to emit a warning.
-
- rendered_metadata_default = _normalize_computed_default(
- rendered_metadata_default
- )
-
- if isinstance(conn_col.server_default, sa_schema.Computed):
- rendered_conn_default = str(
- conn_col.server_default.sqltext.compile(
- dialect=autogen_context.dialect,
- compile_kwargs={"literal_binds": True},
- )
- )
- if rendered_conn_default is None:
- rendered_conn_default = ""
- else:
- rendered_conn_default = _normalize_computed_default(
- rendered_conn_default
- )
- else:
- rendered_conn_default = ""
-
- if rendered_metadata_default != rendered_conn_default:
- _warn_computed_not_supported(tname, cname)
-
-
-def _warn_computed_not_supported(tname: str, cname: str) -> None:
- util.warn("Computed default on %s.%s cannot be modified" % (tname, cname))
-
-
-def _compare_identity_default(
- autogen_context,
- alter_column_op,
- schema,
- tname,
- cname,
- conn_col,
- metadata_col,
-):
- impl = autogen_context.migration_context.impl
- diff, ignored_attr, is_alter = impl._compare_identity_default(
- metadata_col.server_default, conn_col.server_default
- )
-
- return diff, is_alter
-
-
-@comparators.dispatch_for("column")
-def _compare_server_default(
- autogen_context: AutogenContext,
- alter_column_op: AlterColumnOp,
- schema: Optional[str],
- tname: Union[quoted_name, str],
- cname: Union[quoted_name, str],
- conn_col: Column[Any],
- metadata_col: Column[Any],
-) -> Optional[bool]:
- metadata_default = metadata_col.server_default
- conn_col_default = conn_col.server_default
- if conn_col_default is None and metadata_default is None:
- return False
-
- if sqla_compat._server_default_is_computed(metadata_default):
- # return False in case of a computed column as the server
- # default. Note that DDL for adding or removing "GENERATED AS" from
- # an existing column is not currently known for any backend.
- # Once SQLAlchemy can reflect "GENERATED" as the "computed" element,
- # we would also want to ignore and/or warn for changes vs. the
- # metadata (or support backend specific DDL if applicable).
- if not sqla_compat.has_computed_reflection:
- return False
-
- else:
- return (
- _compare_computed_default( # type:ignore[func-returns-value]
- autogen_context,
- alter_column_op,
- schema,
- tname,
- cname,
- conn_col,
- metadata_col,
- )
- )
- if sqla_compat._server_default_is_computed(conn_col_default):
- _warn_computed_not_supported(tname, cname)
- return False
-
- if sqla_compat._server_default_is_identity(
- metadata_default, conn_col_default
- ):
- alter_column_op.existing_server_default = conn_col_default
- diff, is_alter = _compare_identity_default(
- autogen_context,
- alter_column_op,
- schema,
- tname,
- cname,
- conn_col,
- metadata_col,
- )
- if is_alter:
- alter_column_op.modify_server_default = metadata_default
- if diff:
- log.info(
- "Detected server default on column '%s.%s': "
- "identity options attributes %s",
- tname,
- cname,
- sorted(diff),
- )
- else:
- rendered_metadata_default = _render_server_default_for_compare(
- metadata_default, autogen_context
- )
-
- rendered_conn_default = (
- cast(Any, conn_col_default).arg.text if conn_col_default else None
- )
-
- alter_column_op.existing_server_default = conn_col_default
-
- is_diff = autogen_context.migration_context._compare_server_default(
- conn_col,
- metadata_col,
- rendered_metadata_default,
- rendered_conn_default,
- )
- if is_diff:
- alter_column_op.modify_server_default = metadata_default
- log.info("Detected server default on column '%s.%s'", tname, cname)
-
- return None
-
-
-@comparators.dispatch_for("column")
-def _compare_column_comment(
- autogen_context: AutogenContext,
- alter_column_op: AlterColumnOp,
- schema: Optional[str],
- tname: Union[quoted_name, str],
- cname: quoted_name,
- conn_col: Column[Any],
- metadata_col: Column[Any],
-) -> Optional[Literal[False]]:
- assert autogen_context.dialect is not None
- if not autogen_context.dialect.supports_comments:
- return None
-
- metadata_comment = metadata_col.comment
- conn_col_comment = conn_col.comment
- if conn_col_comment is None and metadata_comment is None:
- return False
-
- alter_column_op.existing_comment = conn_col_comment
-
- if conn_col_comment != metadata_comment:
- alter_column_op.modify_comment = metadata_comment
- log.info("Detected column comment '%s.%s'", tname, cname)
-
- return None
-
-
-@comparators.dispatch_for("table")
-def _compare_foreign_keys(
- autogen_context: AutogenContext,
- modify_table_ops: ModifyTableOps,
- schema: Optional[str],
- tname: Union[quoted_name, str],
- conn_table: Table,
- metadata_table: Table,
-) -> None:
- # if we're doing CREATE TABLE, all FKs are created
- # inline within the table def
- if conn_table is None or metadata_table is None:
- return
-
- inspector = autogen_context.inspector
- metadata_fks = {
- fk
- for fk in metadata_table.constraints
- if isinstance(fk, sa_schema.ForeignKeyConstraint)
- }
-
- conn_fks_list = [
- fk
- for fk in inspector.get_foreign_keys(tname, schema=schema)
- if autogen_context.run_name_filters(
- fk["name"],
- "foreign_key_constraint",
- {"table_name": tname, "schema_name": schema},
- )
- ]
-
- conn_fks = {
- _make_foreign_key(const, conn_table) # type: ignore[arg-type]
- for const in conn_fks_list
- }
-
- impl = autogen_context.migration_context.impl
-
- # give the dialect a chance to correct the FKs to match more
- # closely
- autogen_context.migration_context.impl.correct_for_autogen_foreignkeys(
- conn_fks, metadata_fks
- )
-
- metadata_fks_sig = {
- impl._create_metadata_constraint_sig(fk) for fk in metadata_fks
- }
-
- conn_fks_sig = {
- impl._create_reflected_constraint_sig(fk) for fk in conn_fks
- }
-
- # check if reflected FKs include options, indicating the backend
- # can reflect FK options
- if conn_fks_list and "options" in conn_fks_list[0]:
- conn_fks_by_sig = {c.unnamed: c for c in conn_fks_sig}
- metadata_fks_by_sig = {c.unnamed: c for c in metadata_fks_sig}
- else:
- # otherwise compare by sig without options added
- conn_fks_by_sig = {c.unnamed_no_options: c for c in conn_fks_sig}
- metadata_fks_by_sig = {
- c.unnamed_no_options: c for c in metadata_fks_sig
- }
-
- metadata_fks_by_name = {
- c.name: c for c in metadata_fks_sig if c.name is not None
- }
- conn_fks_by_name = {c.name: c for c in conn_fks_sig if c.name is not None}
-
- def _add_fk(obj, compare_to):
- if autogen_context.run_object_filters(
- obj.const, obj.name, "foreign_key_constraint", False, compare_to
- ):
- modify_table_ops.ops.append(
- ops.CreateForeignKeyOp.from_constraint(const.const) # type: ignore[has-type] # noqa: E501
- )
-
- log.info(
- "Detected added foreign key (%s)(%s) on table %s%s",
- ", ".join(obj.source_columns),
- ", ".join(obj.target_columns),
- "%s." % obj.source_schema if obj.source_schema else "",
- obj.source_table,
- )
-
- def _remove_fk(obj, compare_to):
- if autogen_context.run_object_filters(
- obj.const, obj.name, "foreign_key_constraint", True, compare_to
- ):
- modify_table_ops.ops.append(
- ops.DropConstraintOp.from_constraint(obj.const)
- )
- log.info(
- "Detected removed foreign key (%s)(%s) on table %s%s",
- ", ".join(obj.source_columns),
- ", ".join(obj.target_columns),
- "%s." % obj.source_schema if obj.source_schema else "",
- obj.source_table,
- )
-
- # so far it appears we don't need to do this by name at all.
- # SQLite doesn't preserve constraint names anyway
-
- for removed_sig in set(conn_fks_by_sig).difference(metadata_fks_by_sig):
- const = conn_fks_by_sig[removed_sig]
- if removed_sig not in metadata_fks_by_sig:
- compare_to = (
- metadata_fks_by_name[const.name].const
- if const.name in metadata_fks_by_name
- else None
- )
- _remove_fk(const, compare_to)
-
- for added_sig in set(metadata_fks_by_sig).difference(conn_fks_by_sig):
- const = metadata_fks_by_sig[added_sig]
- if added_sig not in conn_fks_by_sig:
- compare_to = (
- conn_fks_by_name[const.name].const
- if const.name in conn_fks_by_name
- else None
- )
- _add_fk(const, compare_to)
-
-
-@comparators.dispatch_for("table")
-def _compare_table_comment(
- autogen_context: AutogenContext,
- modify_table_ops: ModifyTableOps,
- schema: Optional[str],
- tname: Union[quoted_name, str],
- conn_table: Optional[Table],
- metadata_table: Optional[Table],
-) -> None:
- assert autogen_context.dialect is not None
- if not autogen_context.dialect.supports_comments:
- return
-
- # if we're doing CREATE TABLE, comments will be created inline
- # with the create_table op.
- if conn_table is None or metadata_table is None:
- return
-
- if conn_table.comment is None and metadata_table.comment is None:
- return
-
- if metadata_table.comment is None and conn_table.comment is not None:
- modify_table_ops.ops.append(
- ops.DropTableCommentOp(
- tname, existing_comment=conn_table.comment, schema=schema
- )
- )
- elif metadata_table.comment != conn_table.comment:
- modify_table_ops.ops.append(
- ops.CreateTableCommentOp(
- tname,
- metadata_table.comment,
- existing_comment=conn_table.comment,
- schema=schema,
- )
- )
diff --git a/libs/alembic/autogenerate/compare/__init__.py b/libs/alembic/autogenerate/compare/__init__.py
new file mode 100644
index 0000000000..a49640cf86
--- /dev/null
+++ b/libs/alembic/autogenerate/compare/__init__.py
@@ -0,0 +1,62 @@
+from __future__ import annotations
+
+import logging
+from typing import TYPE_CHECKING
+
+from . import comments
+from . import constraints
+from . import schema
+from . import server_defaults
+from . import tables
+from . import types
+from ... import util
+from ...runtime.plugins import Plugin
+
+if TYPE_CHECKING:
+ from ..api import AutogenContext
+ from ...operations.ops import MigrationScript
+ from ...operations.ops import UpgradeOps
+
+
+log = logging.getLogger(__name__)
+
+comparators = util.PriorityDispatcher()
+"""global registry which alembic keeps empty, but copies when creating
+a new AutogenContext.
+
+This is to support a variety of third party plugins that hook their autogen
+functionality onto this collection.
+
+"""
+
+
+def _populate_migration_script(
+ autogen_context: AutogenContext, migration_script: MigrationScript
+) -> None:
+ upgrade_ops = migration_script.upgrade_ops_list[-1]
+ downgrade_ops = migration_script.downgrade_ops_list[-1]
+
+ _produce_net_changes(autogen_context, upgrade_ops)
+ upgrade_ops.reverse_into(downgrade_ops)
+
+
+def _produce_net_changes(
+ autogen_context: AutogenContext, upgrade_ops: UpgradeOps
+) -> None:
+ assert autogen_context.dialect is not None
+
+ autogen_context.comparators.dispatch(
+ "autogenerate", qualifier=autogen_context.dialect.name
+ )(autogen_context, upgrade_ops)
+
+
+Plugin.setup_plugin_from_module(schema, "alembic.autogenerate.schemas")
+Plugin.setup_plugin_from_module(tables, "alembic.autogenerate.tables")
+Plugin.setup_plugin_from_module(types, "alembic.autogenerate.types")
+Plugin.setup_plugin_from_module(
+ constraints, "alembic.autogenerate.constraints"
+)
+Plugin.setup_plugin_from_module(
+ server_defaults, "alembic.autogenerate.defaults"
+)
+Plugin.setup_plugin_from_module(comments, "alembic.autogenerate.comments")
diff --git a/libs/alembic/autogenerate/compare/comments.py b/libs/alembic/autogenerate/compare/comments.py
new file mode 100644
index 0000000000..70de74e2d7
--- /dev/null
+++ b/libs/alembic/autogenerate/compare/comments.py
@@ -0,0 +1,106 @@
+from __future__ import annotations
+
+import logging
+from typing import Any
+from typing import Optional
+from typing import TYPE_CHECKING
+from typing import Union
+
+from ...operations import ops
+from ...util import PriorityDispatchResult
+
+if TYPE_CHECKING:
+
+ from sqlalchemy.sql.elements import quoted_name
+ from sqlalchemy.sql.schema import Column
+ from sqlalchemy.sql.schema import Table
+
+ from ..api import AutogenContext
+ from ...operations.ops import AlterColumnOp
+ from ...operations.ops import ModifyTableOps
+ from ...runtime.plugins import Plugin
+
+log = logging.getLogger(__name__)
+
+
+def _compare_column_comment(
+ autogen_context: AutogenContext,
+ alter_column_op: AlterColumnOp,
+ schema: Optional[str],
+ tname: Union[quoted_name, str],
+ cname: quoted_name,
+ conn_col: Column[Any],
+ metadata_col: Column[Any],
+) -> PriorityDispatchResult:
+ assert autogen_context.dialect is not None
+ if not autogen_context.dialect.supports_comments:
+ return PriorityDispatchResult.CONTINUE
+
+ metadata_comment = metadata_col.comment
+ conn_col_comment = conn_col.comment
+ if conn_col_comment is None and metadata_comment is None:
+ return PriorityDispatchResult.CONTINUE
+
+ alter_column_op.existing_comment = conn_col_comment
+
+ if conn_col_comment != metadata_comment:
+ alter_column_op.modify_comment = metadata_comment
+ log.info("Detected column comment '%s.%s'", tname, cname)
+
+ return PriorityDispatchResult.STOP
+ else:
+ return PriorityDispatchResult.CONTINUE
+
+
+def _compare_table_comment(
+ autogen_context: AutogenContext,
+ modify_table_ops: ModifyTableOps,
+ schema: Optional[str],
+ tname: Union[quoted_name, str],
+ conn_table: Optional[Table],
+ metadata_table: Optional[Table],
+) -> PriorityDispatchResult:
+ assert autogen_context.dialect is not None
+ if not autogen_context.dialect.supports_comments:
+ return PriorityDispatchResult.CONTINUE
+
+ # if we're doing CREATE TABLE, comments will be created inline
+ # with the create_table op.
+ if conn_table is None or metadata_table is None:
+ return PriorityDispatchResult.CONTINUE
+
+ if conn_table.comment is None and metadata_table.comment is None:
+ return PriorityDispatchResult.CONTINUE
+
+ if metadata_table.comment is None and conn_table.comment is not None:
+ modify_table_ops.ops.append(
+ ops.DropTableCommentOp(
+ tname, existing_comment=conn_table.comment, schema=schema
+ )
+ )
+ return PriorityDispatchResult.STOP
+ elif metadata_table.comment != conn_table.comment:
+ modify_table_ops.ops.append(
+ ops.CreateTableCommentOp(
+ tname,
+ metadata_table.comment,
+ existing_comment=conn_table.comment,
+ schema=schema,
+ )
+ )
+ return PriorityDispatchResult.STOP
+
+ return PriorityDispatchResult.CONTINUE
+
+
+def setup(plugin: Plugin) -> None:
+ plugin.add_autogenerate_comparator(
+ _compare_column_comment,
+ "column",
+ "comments",
+ )
+ plugin.add_autogenerate_comparator(
+ _compare_table_comment,
+ "table",
+ "comments",
+ )
diff --git a/libs/alembic/autogenerate/compare/constraints.py b/libs/alembic/autogenerate/compare/constraints.py
new file mode 100644
index 0000000000..ae1f20e4b1
--- /dev/null
+++ b/libs/alembic/autogenerate/compare/constraints.py
@@ -0,0 +1,812 @@
+# mypy: allow-untyped-defs, allow-untyped-calls, allow-incomplete-defs
+
+from __future__ import annotations
+
+import logging
+from typing import Any
+from typing import cast
+from typing import Collection
+from typing import Dict
+from typing import Mapping
+from typing import Optional
+from typing import TYPE_CHECKING
+from typing import TypeVar
+from typing import Union
+
+from sqlalchemy import schema as sa_schema
+from sqlalchemy import text
+from sqlalchemy.sql import expression
+from sqlalchemy.sql.schema import ForeignKeyConstraint
+from sqlalchemy.sql.schema import Index
+from sqlalchemy.sql.schema import UniqueConstraint
+
+from .util import _InspectorConv
+from ... import util
+from ...ddl._autogen import is_index_sig
+from ...ddl._autogen import is_uq_sig
+from ...operations import ops
+from ...util import PriorityDispatchResult
+from ...util import sqla_compat
+
+if TYPE_CHECKING:
+ from sqlalchemy.engine.interfaces import ReflectedForeignKeyConstraint
+ from sqlalchemy.engine.interfaces import ReflectedIndex
+ from sqlalchemy.engine.interfaces import ReflectedUniqueConstraint
+ from sqlalchemy.sql.elements import quoted_name
+ from sqlalchemy.sql.elements import TextClause
+ from sqlalchemy.sql.schema import Column
+ from sqlalchemy.sql.schema import Table
+
+ from ...autogenerate.api import AutogenContext
+ from ...ddl._autogen import _constraint_sig
+ from ...ddl.impl import DefaultImpl
+ from ...operations.ops import AlterColumnOp
+ from ...operations.ops import ModifyTableOps
+ from ...runtime.plugins import Plugin
+
+_C = TypeVar("_C", bound=Union[UniqueConstraint, ForeignKeyConstraint, Index])
+
+
+log = logging.getLogger(__name__)
+
+
+def _compare_indexes_and_uniques(
+ autogen_context: AutogenContext,
+ modify_ops: ModifyTableOps,
+ schema: Optional[str],
+ tname: Union[quoted_name, str],
+ conn_table: Optional[Table],
+ metadata_table: Optional[Table],
+) -> PriorityDispatchResult:
+ inspector = autogen_context.inspector
+ is_create_table = conn_table is None
+ is_drop_table = metadata_table is None
+ impl = autogen_context.migration_context.impl
+
+ # 1a. get raw indexes and unique constraints from metadata ...
+ if metadata_table is not None:
+ metadata_unique_constraints = {
+ uq
+ for uq in metadata_table.constraints
+ if isinstance(uq, sa_schema.UniqueConstraint)
+ }
+ metadata_indexes = set(metadata_table.indexes)
+ else:
+ metadata_unique_constraints = set()
+ metadata_indexes = set()
+
+ conn_uniques: Collection[UniqueConstraint] = frozenset()
+ conn_indexes: Collection[Index] = frozenset()
+
+ supports_unique_constraints = False
+
+ unique_constraints_duplicate_unique_indexes = False
+
+ if conn_table is not None:
+ conn_uniques_reflected: Collection[ReflectedUniqueConstraint] = (
+ frozenset()
+ )
+ conn_indexes_reflected: Collection[ReflectedIndex] = frozenset()
+
+ # 1b. ... and from connection, if the table exists
+ try:
+ conn_uniques_reflected = _InspectorConv(
+ inspector
+ ).get_unique_constraints(tname, schema=schema)
+
+ supports_unique_constraints = True
+ except NotImplementedError:
+ pass
+ except TypeError:
+ # number of arguments is off for the base
+ # method in SQLAlchemy due to the cache decorator
+ # not being present
+ pass
+ else:
+ conn_uniques_reflected = [
+ uq
+ for uq in conn_uniques_reflected
+ if autogen_context.run_name_filters(
+ uq["name"],
+ "unique_constraint",
+ {"table_name": tname, "schema_name": schema},
+ )
+ ]
+ for uq in conn_uniques_reflected:
+ if uq.get("duplicates_index"):
+ unique_constraints_duplicate_unique_indexes = True
+ try:
+ conn_indexes_reflected = _InspectorConv(inspector).get_indexes(
+ tname, schema=schema
+ )
+ except NotImplementedError:
+ pass
+ else:
+ conn_indexes_reflected = [
+ ix
+ for ix in conn_indexes_reflected
+ if autogen_context.run_name_filters(
+ ix["name"],
+ "index",
+ {"table_name": tname, "schema_name": schema},
+ )
+ ]
+
+ # 2. convert conn-level objects from raw inspector records
+ # into schema objects
+ if is_drop_table:
+ # for DROP TABLE uniques are inline, don't need them
+ conn_uniques = set()
+ else:
+ conn_uniques = {
+ _make_unique_constraint(impl, uq_def, conn_table)
+ for uq_def in conn_uniques_reflected
+ }
+
+ conn_indexes = {
+ index
+ for index in (
+ _make_index(impl, ix, conn_table)
+ for ix in conn_indexes_reflected
+ )
+ if index is not None
+ }
+
+ # 2a. if the dialect dupes unique indexes as unique constraints
+ # (mysql and oracle), correct for that
+
+ if unique_constraints_duplicate_unique_indexes:
+ _correct_for_uq_duplicates_uix(
+ conn_uniques,
+ conn_indexes,
+ metadata_unique_constraints,
+ metadata_indexes,
+ autogen_context.dialect,
+ impl,
+ )
+
+ # 3. give the dialect a chance to omit indexes and constraints that
+ # we know are either added implicitly by the DB or that the DB
+ # can't accurately report on
+ impl.correct_for_autogen_constraints(
+ conn_uniques, # type: ignore[arg-type]
+ conn_indexes, # type: ignore[arg-type]
+ metadata_unique_constraints,
+ metadata_indexes,
+ )
+
+ # 4. organize the constraints into "signature" collections, the
+ # _constraint_sig() objects provide a consistent facade over both
+ # Index and UniqueConstraint so we can easily work with them
+ # interchangeably
+ metadata_unique_constraints_sig = {
+ impl._create_metadata_constraint_sig(uq)
+ for uq in metadata_unique_constraints
+ }
+
+ metadata_indexes_sig = {
+ impl._create_metadata_constraint_sig(ix) for ix in metadata_indexes
+ }
+
+ conn_unique_constraints = {
+ impl._create_reflected_constraint_sig(uq) for uq in conn_uniques
+ }
+
+ conn_indexes_sig = {
+ impl._create_reflected_constraint_sig(ix) for ix in conn_indexes
+ }
+
+ # 5. index things by name, for those objects that have names
+ metadata_names = {
+ cast(str, c.md_name_to_sql_name(autogen_context)): c
+ for c in metadata_unique_constraints_sig.union(metadata_indexes_sig)
+ if c.is_named
+ }
+
+ conn_uniques_by_name: Dict[
+ sqla_compat._ConstraintName,
+ _constraint_sig[sa_schema.UniqueConstraint],
+ ]
+ conn_indexes_by_name: Dict[
+ sqla_compat._ConstraintName, _constraint_sig[sa_schema.Index]
+ ]
+
+ conn_uniques_by_name = {c.name: c for c in conn_unique_constraints}
+ conn_indexes_by_name = {c.name: c for c in conn_indexes_sig}
+ conn_names = {
+ c.name: c
+ for c in conn_unique_constraints.union(conn_indexes_sig)
+ if sqla_compat.constraint_name_string(c.name)
+ }
+
+ doubled_constraints = {
+ name: (conn_uniques_by_name[name], conn_indexes_by_name[name])
+ for name in set(conn_uniques_by_name).intersection(
+ conn_indexes_by_name
+ )
+ }
+
+ # 6. index things by "column signature", to help with unnamed unique
+ # constraints.
+ conn_uniques_by_sig = {uq.unnamed: uq for uq in conn_unique_constraints}
+ metadata_uniques_by_sig = {
+ uq.unnamed: uq for uq in metadata_unique_constraints_sig
+ }
+ unnamed_metadata_uniques = {
+ uq.unnamed: uq
+ for uq in metadata_unique_constraints_sig
+ if not sqla_compat._constraint_is_named(
+ uq.const, autogen_context.dialect
+ )
+ }
+
+ # assumptions:
+ # 1. a unique constraint or an index from the connection *always*
+ # has a name.
+ # 2. an index on the metadata side *always* has a name.
+ # 3. a unique constraint on the metadata side *might* have a name.
+ # 4. The backend may double up indexes as unique constraints and
+ # vice versa (e.g. MySQL, Postgresql)
+
+ def obj_added(
+ obj: (
+ _constraint_sig[sa_schema.UniqueConstraint]
+ | _constraint_sig[sa_schema.Index]
+ ),
+ ):
+ if is_index_sig(obj):
+ if autogen_context.run_object_filters(
+ obj.const, obj.name, "index", False, None
+ ):
+ modify_ops.ops.append(ops.CreateIndexOp.from_index(obj.const))
+ log.info(
+ "Detected added index %r on '%s'",
+ obj.name,
+ obj.column_names,
+ )
+ elif is_uq_sig(obj):
+ if not supports_unique_constraints:
+ # can't report unique indexes as added if we don't
+ # detect them
+ return
+ if is_create_table or is_drop_table:
+ # unique constraints are created inline with table defs
+ return
+ if autogen_context.run_object_filters(
+ obj.const, obj.name, "unique_constraint", False, None
+ ):
+ modify_ops.ops.append(
+ ops.AddConstraintOp.from_constraint(obj.const)
+ )
+ log.info(
+ "Detected added unique constraint %r on '%s'",
+ obj.name,
+ obj.column_names,
+ )
+ else:
+ assert False
+
+ def obj_removed(
+ obj: (
+ _constraint_sig[sa_schema.UniqueConstraint]
+ | _constraint_sig[sa_schema.Index]
+ ),
+ ):
+ if is_index_sig(obj):
+ if obj.is_unique and not supports_unique_constraints:
+ # many databases double up unique constraints
+ # as unique indexes. without that list we can't
+ # be sure what we're doing here
+ return
+
+ if autogen_context.run_object_filters(
+ obj.const, obj.name, "index", True, None
+ ):
+ modify_ops.ops.append(ops.DropIndexOp.from_index(obj.const))
+ log.info("Detected removed index %r on %r", obj.name, tname)
+ elif is_uq_sig(obj):
+ if is_create_table or is_drop_table:
+ # if the whole table is being dropped, we don't need to
+ # consider unique constraint separately
+ return
+ if autogen_context.run_object_filters(
+ obj.const, obj.name, "unique_constraint", True, None
+ ):
+ modify_ops.ops.append(
+ ops.DropConstraintOp.from_constraint(obj.const)
+ )
+ log.info(
+ "Detected removed unique constraint %r on %r",
+ obj.name,
+ tname,
+ )
+ else:
+ assert False
+
+ def obj_changed(
+ old: (
+ _constraint_sig[sa_schema.UniqueConstraint]
+ | _constraint_sig[sa_schema.Index]
+ ),
+ new: (
+ _constraint_sig[sa_schema.UniqueConstraint]
+ | _constraint_sig[sa_schema.Index]
+ ),
+ msg: str,
+ ):
+ if is_index_sig(old):
+ assert is_index_sig(new)
+
+ if autogen_context.run_object_filters(
+ new.const, new.name, "index", False, old.const
+ ):
+ log.info(
+ "Detected changed index %r on %r: %s", old.name, tname, msg
+ )
+ modify_ops.ops.append(ops.DropIndexOp.from_index(old.const))
+ modify_ops.ops.append(ops.CreateIndexOp.from_index(new.const))
+ elif is_uq_sig(old):
+ assert is_uq_sig(new)
+
+ if autogen_context.run_object_filters(
+ new.const, new.name, "unique_constraint", False, old.const
+ ):
+ log.info(
+ "Detected changed unique constraint %r on %r: %s",
+ old.name,
+ tname,
+ msg,
+ )
+ modify_ops.ops.append(
+ ops.DropConstraintOp.from_constraint(old.const)
+ )
+ modify_ops.ops.append(
+ ops.AddConstraintOp.from_constraint(new.const)
+ )
+ else:
+ assert False
+
+ for removed_name in sorted(set(conn_names).difference(metadata_names)):
+ conn_obj = conn_names[removed_name]
+ if (
+ is_uq_sig(conn_obj)
+ and conn_obj.unnamed in unnamed_metadata_uniques
+ ):
+ continue
+ elif removed_name in doubled_constraints:
+ conn_uq, conn_idx = doubled_constraints[removed_name]
+ if (
+ all(
+ conn_idx.unnamed != meta_idx.unnamed
+ for meta_idx in metadata_indexes_sig
+ )
+ and conn_uq.unnamed not in metadata_uniques_by_sig
+ ):
+ obj_removed(conn_uq)
+ obj_removed(conn_idx)
+ else:
+ obj_removed(conn_obj)
+
+ for existing_name in sorted(set(metadata_names).intersection(conn_names)):
+ metadata_obj = metadata_names[existing_name]
+
+ if existing_name in doubled_constraints:
+ conn_uq, conn_idx = doubled_constraints[existing_name]
+ if is_index_sig(metadata_obj):
+ conn_obj = conn_idx
+ else:
+ conn_obj = conn_uq
+ else:
+ conn_obj = conn_names[existing_name]
+
+ if type(conn_obj) != type(metadata_obj):
+ obj_removed(conn_obj)
+ obj_added(metadata_obj)
+ else:
+ # TODO: for plugins, let's do is_index_sig / is_uq_sig
+ # here so we know index or unique, then
+ # do a sub-dispatch,
+ # autogen_context.comparators.dispatch("index")
+ # or
+ # autogen_context.comparators.dispatch("unique_constraint")
+ #
+ comparison = metadata_obj.compare_to_reflected(conn_obj)
+
+ if comparison.is_different:
+ # constraint are different
+ obj_changed(conn_obj, metadata_obj, comparison.message)
+ elif comparison.is_skip:
+ # constraint cannot be compared, skip them
+ thing = (
+ "index" if is_index_sig(conn_obj) else "unique constraint"
+ )
+ log.info(
+ "Cannot compare %s %r, assuming equal and skipping. %s",
+ thing,
+ conn_obj.name,
+ comparison.message,
+ )
+ else:
+ # constraint are equal
+ assert comparison.is_equal
+
+ for added_name in sorted(set(metadata_names).difference(conn_names)):
+ obj = metadata_names[added_name]
+ obj_added(obj)
+
+ for uq_sig in unnamed_metadata_uniques:
+ if uq_sig not in conn_uniques_by_sig:
+ obj_added(unnamed_metadata_uniques[uq_sig])
+
+ return PriorityDispatchResult.CONTINUE
+
+
+def _correct_for_uq_duplicates_uix(
+ conn_unique_constraints,
+ conn_indexes,
+ metadata_unique_constraints,
+ metadata_indexes,
+ dialect,
+ impl,
+):
+ # dedupe unique indexes vs. constraints, since MySQL / Oracle
+ # doesn't really have unique constraints as a separate construct.
+ # but look in the metadata and try to maintain constructs
+ # that already seem to be defined one way or the other
+ # on that side. This logic was formerly local to MySQL dialect,
+ # generalized to Oracle and others. See #276
+
+ # resolve final rendered name for unique constraints defined in the
+ # metadata. this includes truncation of long names. naming convention
+ # names currently should already be set as cons.name, however leave this
+ # to the sqla_compat to decide.
+ metadata_cons_names = [
+ (sqla_compat._get_constraint_final_name(cons, dialect), cons)
+ for cons in metadata_unique_constraints
+ ]
+
+ metadata_uq_names = {
+ name for name, cons in metadata_cons_names if name is not None
+ }
+
+ unnamed_metadata_uqs = {
+ impl._create_metadata_constraint_sig(cons).unnamed
+ for name, cons in metadata_cons_names
+ if name is None
+ }
+
+ metadata_ix_names = {
+ sqla_compat._get_constraint_final_name(cons, dialect)
+ for cons in metadata_indexes
+ if cons.unique
+ }
+
+ # for reflection side, names are in their final database form
+ # already since they're from the database
+ conn_ix_names = {cons.name: cons for cons in conn_indexes if cons.unique}
+
+ uqs_dupe_indexes = {
+ cons.name: cons
+ for cons in conn_unique_constraints
+ if cons.info["duplicates_index"]
+ }
+
+ for overlap in uqs_dupe_indexes:
+ if overlap not in metadata_uq_names:
+ if (
+ impl._create_reflected_constraint_sig(
+ uqs_dupe_indexes[overlap]
+ ).unnamed
+ not in unnamed_metadata_uqs
+ ):
+ conn_unique_constraints.discard(uqs_dupe_indexes[overlap])
+ elif overlap not in metadata_ix_names:
+ conn_indexes.discard(conn_ix_names[overlap])
+
+
+_IndexColumnSortingOps: Mapping[str, Any] = util.immutabledict(
+ {
+ "asc": expression.asc,
+ "desc": expression.desc,
+ "nulls_first": expression.nullsfirst,
+ "nulls_last": expression.nullslast,
+ "nullsfirst": expression.nullsfirst, # 1_3 name
+ "nullslast": expression.nullslast, # 1_3 name
+ }
+)
+
+
+def _make_index(
+ impl: DefaultImpl, params: ReflectedIndex, conn_table: Table
+) -> Optional[Index]:
+ exprs: list[Union[Column[Any], TextClause]] = []
+ sorting = params.get("column_sorting")
+
+ for num, col_name in enumerate(params["column_names"]):
+ item: Union[Column[Any], TextClause]
+ if col_name is None:
+ assert "expressions" in params
+ name = params["expressions"][num]
+ item = text(name)
+ else:
+ name = col_name
+ item = conn_table.c[col_name]
+ if sorting and name in sorting:
+ for operator in sorting[name]:
+ if operator in _IndexColumnSortingOps:
+ item = _IndexColumnSortingOps[operator](item)
+ exprs.append(item)
+ ix = sa_schema.Index(
+ params["name"],
+ *exprs,
+ unique=params["unique"],
+ _table=conn_table,
+ **impl.adjust_reflected_dialect_options(params, "index"),
+ )
+ if "duplicates_constraint" in params:
+ ix.info["duplicates_constraint"] = params["duplicates_constraint"]
+ return ix
+
+
+def _make_unique_constraint(
+ impl: DefaultImpl, params: ReflectedUniqueConstraint, conn_table: Table
+) -> UniqueConstraint:
+ uq = sa_schema.UniqueConstraint(
+ *[conn_table.c[cname] for cname in params["column_names"]],
+ name=params["name"],
+ **impl.adjust_reflected_dialect_options(params, "unique_constraint"),
+ )
+ if "duplicates_index" in params:
+ uq.info["duplicates_index"] = params["duplicates_index"]
+
+ return uq
+
+
+def _make_foreign_key(
+ params: ReflectedForeignKeyConstraint, conn_table: Table
+) -> ForeignKeyConstraint:
+ tname = params["referred_table"]
+ if params["referred_schema"]:
+ tname = "%s.%s" % (params["referred_schema"], tname)
+
+ options = params.get("options", {})
+
+ const = sa_schema.ForeignKeyConstraint(
+ [conn_table.c[cname] for cname in params["constrained_columns"]],
+ ["%s.%s" % (tname, n) for n in params["referred_columns"]],
+ onupdate=options.get("onupdate"),
+ ondelete=options.get("ondelete"),
+ deferrable=options.get("deferrable"),
+ initially=options.get("initially"),
+ name=params["name"],
+ )
+
+ referred_schema = params["referred_schema"]
+ referred_table = params["referred_table"]
+
+ remote_table_key = sqla_compat._get_table_key(
+ referred_table, referred_schema
+ )
+ if remote_table_key not in conn_table.metadata:
+ # create a placeholder table
+ sa_schema.Table(
+ referred_table,
+ conn_table.metadata,
+ schema=(
+ referred_schema
+ if referred_schema is not None
+ else sa_schema.BLANK_SCHEMA
+ ),
+ *[
+ sa_schema.Column(remote, conn_table.c[local].type)
+ for local, remote in zip(
+ params["constrained_columns"], params["referred_columns"]
+ )
+ ],
+ info={"alembic_placeholder": True},
+ )
+ elif conn_table.metadata.tables[remote_table_key].info.get(
+ "alembic_placeholder"
+ ):
+ # table exists and is a placeholder; ensure needed columns are present
+ placeholder_table = conn_table.metadata.tables[remote_table_key]
+ for local, remote in zip(
+ params["constrained_columns"], params["referred_columns"]
+ ):
+ if remote not in placeholder_table.c:
+ placeholder_table.append_column(
+ sa_schema.Column(remote, conn_table.c[local].type)
+ )
+
+ # needed by 0.7
+ conn_table.append_constraint(const)
+ return const
+
+
+def _compare_foreign_keys(
+ autogen_context: AutogenContext,
+ modify_table_ops: ModifyTableOps,
+ schema: Optional[str],
+ tname: Union[quoted_name, str],
+ conn_table: Table,
+ metadata_table: Table,
+) -> PriorityDispatchResult:
+ # if we're doing CREATE TABLE, all FKs are created
+ # inline within the table def
+
+ if conn_table is None or metadata_table is None:
+ return PriorityDispatchResult.CONTINUE
+
+ inspector = autogen_context.inspector
+ metadata_fks = {
+ fk
+ for fk in metadata_table.constraints
+ if isinstance(fk, sa_schema.ForeignKeyConstraint)
+ }
+
+ conn_fks_list = [
+ fk
+ for fk in _InspectorConv(inspector).get_foreign_keys(
+ tname, schema=schema
+ )
+ if autogen_context.run_name_filters(
+ fk["name"],
+ "foreign_key_constraint",
+ {"table_name": tname, "schema_name": schema},
+ )
+ ]
+
+ conn_fks = {
+ _make_foreign_key(const, conn_table) for const in conn_fks_list
+ }
+
+ impl = autogen_context.migration_context.impl
+
+ # give the dialect a chance to correct the FKs to match more
+ # closely
+ autogen_context.migration_context.impl.correct_for_autogen_foreignkeys(
+ conn_fks, metadata_fks
+ )
+
+ metadata_fks_sig = {
+ impl._create_metadata_constraint_sig(fk) for fk in metadata_fks
+ }
+
+ conn_fks_sig = {
+ impl._create_reflected_constraint_sig(fk) for fk in conn_fks
+ }
+
+ # check if reflected FKs include options, indicating the backend
+ # can reflect FK options
+ if conn_fks_list and "options" in conn_fks_list[0]:
+ conn_fks_by_sig = {c.unnamed: c for c in conn_fks_sig}
+ metadata_fks_by_sig = {c.unnamed: c for c in metadata_fks_sig}
+ else:
+ # otherwise compare by sig without options added
+ conn_fks_by_sig = {c.unnamed_no_options: c for c in conn_fks_sig}
+ metadata_fks_by_sig = {
+ c.unnamed_no_options: c for c in metadata_fks_sig
+ }
+
+ metadata_fks_by_name = {
+ c.name: c for c in metadata_fks_sig if c.name is not None
+ }
+ conn_fks_by_name = {c.name: c for c in conn_fks_sig if c.name is not None}
+
+ def _add_fk(obj, compare_to):
+ if autogen_context.run_object_filters(
+ obj.const, obj.name, "foreign_key_constraint", False, compare_to
+ ):
+ modify_table_ops.ops.append(
+ ops.CreateForeignKeyOp.from_constraint(const.const)
+ )
+
+ log.info(
+ "Detected added foreign key (%s)(%s) on table %s%s",
+ ", ".join(obj.source_columns),
+ ", ".join(obj.target_columns),
+ "%s." % obj.source_schema if obj.source_schema else "",
+ obj.source_table,
+ )
+
+ def _remove_fk(obj, compare_to):
+ if autogen_context.run_object_filters(
+ obj.const, obj.name, "foreign_key_constraint", True, compare_to
+ ):
+ modify_table_ops.ops.append(
+ ops.DropConstraintOp.from_constraint(obj.const)
+ )
+ log.info(
+ "Detected removed foreign key (%s)(%s) on table %s%s",
+ ", ".join(obj.source_columns),
+ ", ".join(obj.target_columns),
+ "%s." % obj.source_schema if obj.source_schema else "",
+ obj.source_table,
+ )
+
+ # so far it appears we don't need to do this by name at all.
+ # SQLite doesn't preserve constraint names anyway
+
+ for removed_sig in set(conn_fks_by_sig).difference(metadata_fks_by_sig):
+ const = conn_fks_by_sig[removed_sig]
+ if removed_sig not in metadata_fks_by_sig:
+ compare_to = (
+ metadata_fks_by_name[const.name].const
+ if const.name and const.name in metadata_fks_by_name
+ else None
+ )
+ _remove_fk(const, compare_to)
+
+ for added_sig in set(metadata_fks_by_sig).difference(conn_fks_by_sig):
+ const = metadata_fks_by_sig[added_sig]
+ if added_sig not in conn_fks_by_sig:
+ compare_to = (
+ conn_fks_by_name[const.name].const
+ if const.name and const.name in conn_fks_by_name
+ else None
+ )
+ _add_fk(const, compare_to)
+
+ return PriorityDispatchResult.CONTINUE
+
+
+def _compare_nullable(
+ autogen_context: AutogenContext,
+ alter_column_op: AlterColumnOp,
+ schema: Optional[str],
+ tname: Union[quoted_name, str],
+ cname: Union[quoted_name, str],
+ conn_col: Column[Any],
+ metadata_col: Column[Any],
+) -> PriorityDispatchResult:
+ metadata_col_nullable = metadata_col.nullable
+ conn_col_nullable = conn_col.nullable
+ alter_column_op.existing_nullable = conn_col_nullable
+
+ if conn_col_nullable is not metadata_col_nullable:
+ if (
+ sqla_compat._server_default_is_computed(
+ metadata_col.server_default, conn_col.server_default
+ )
+ and sqla_compat._nullability_might_be_unset(metadata_col)
+ or (
+ sqla_compat._server_default_is_identity(
+ metadata_col.server_default, conn_col.server_default
+ )
+ )
+ ):
+ log.info(
+ "Ignoring nullable change on identity column '%s.%s'",
+ tname,
+ cname,
+ )
+ else:
+ alter_column_op.modify_nullable = metadata_col_nullable
+ log.info(
+ "Detected %s on column '%s.%s'",
+ "NULL" if metadata_col_nullable else "NOT NULL",
+ tname,
+ cname,
+ )
+ # column nullablity changed, no further nullable checks needed
+ return PriorityDispatchResult.STOP
+
+ return PriorityDispatchResult.CONTINUE
+
+
+def setup(plugin: Plugin) -> None:
+ plugin.add_autogenerate_comparator(
+ _compare_indexes_and_uniques,
+ "table",
+ "indexes",
+ )
+ plugin.add_autogenerate_comparator(
+ _compare_foreign_keys,
+ "table",
+ "foreignkeys",
+ )
+ plugin.add_autogenerate_comparator(
+ _compare_nullable,
+ "column",
+ "nullable",
+ )
diff --git a/libs/alembic/autogenerate/compare/schema.py b/libs/alembic/autogenerate/compare/schema.py
new file mode 100644
index 0000000000..1f46aff429
--- /dev/null
+++ b/libs/alembic/autogenerate/compare/schema.py
@@ -0,0 +1,62 @@
+# mypy: allow-untyped-calls
+
+from __future__ import annotations
+
+import logging
+from typing import Optional
+from typing import Set
+from typing import TYPE_CHECKING
+
+from sqlalchemy import inspect
+
+from ...util import PriorityDispatchResult
+
+if TYPE_CHECKING:
+ from sqlalchemy.engine.reflection import Inspector
+
+ from ...autogenerate.api import AutogenContext
+ from ...operations.ops import UpgradeOps
+ from ...runtime.plugins import Plugin
+
+
+log = logging.getLogger(__name__)
+
+
+def _produce_net_changes(
+ autogen_context: AutogenContext, upgrade_ops: UpgradeOps
+) -> PriorityDispatchResult:
+ connection = autogen_context.connection
+ assert connection is not None
+ include_schemas = autogen_context.opts.get("include_schemas", False)
+
+ inspector: Inspector = inspect(connection)
+
+ default_schema = connection.dialect.default_schema_name
+ schemas: Set[Optional[str]]
+ if include_schemas:
+ schemas = set(inspector.get_schema_names())
+ # replace default schema name with None
+ schemas.discard("information_schema")
+ # replace the "default" schema with None
+ schemas.discard(default_schema)
+ schemas.add(None)
+ else:
+ schemas = {None}
+
+ schemas = {
+ s for s in schemas if autogen_context.run_name_filters(s, "schema", {})
+ }
+
+ assert autogen_context.dialect is not None
+ autogen_context.comparators.dispatch(
+ "schema", qualifier=autogen_context.dialect.name
+ )(autogen_context, upgrade_ops, schemas)
+
+ return PriorityDispatchResult.CONTINUE
+
+
+def setup(plugin: Plugin) -> None:
+ plugin.add_autogenerate_comparator(
+ _produce_net_changes,
+ "autogenerate",
+ )
diff --git a/libs/alembic/autogenerate/compare/server_defaults.py b/libs/alembic/autogenerate/compare/server_defaults.py
new file mode 100644
index 0000000000..1e09e8e21a
--- /dev/null
+++ b/libs/alembic/autogenerate/compare/server_defaults.py
@@ -0,0 +1,344 @@
+from __future__ import annotations
+
+import logging
+import re
+from types import NoneType
+from typing import Any
+from typing import cast
+from typing import Optional
+from typing import Sequence
+from typing import TYPE_CHECKING
+from typing import Union
+
+from sqlalchemy import schema as sa_schema
+from sqlalchemy.sql.schema import DefaultClause
+
+from ... import util
+from ...util import DispatchPriority
+from ...util import PriorityDispatchResult
+from ...util import sqla_compat
+
+if TYPE_CHECKING:
+ from sqlalchemy.sql.elements import quoted_name
+ from sqlalchemy.sql.schema import Column
+
+ from ...autogenerate.api import AutogenContext
+ from ...operations.ops import AlterColumnOp
+ from ...runtime.plugins import Plugin
+
+log = logging.getLogger(__name__)
+
+
+def _render_server_default_for_compare(
+ metadata_default: Optional[Any], autogen_context: AutogenContext
+) -> Optional[str]:
+ if isinstance(metadata_default, sa_schema.DefaultClause):
+ if isinstance(metadata_default.arg, str):
+ metadata_default = metadata_default.arg
+ else:
+ metadata_default = str(
+ metadata_default.arg.compile(
+ dialect=autogen_context.dialect,
+ compile_kwargs={"literal_binds": True},
+ )
+ )
+ if isinstance(metadata_default, str):
+ return metadata_default
+ else:
+ return None
+
+
+def _normalize_computed_default(sqltext: str) -> str:
+ """we want to warn if a computed sql expression has changed. however
+ we don't want false positives and the warning is not that critical.
+ so filter out most forms of variability from the SQL text.
+
+ """
+
+ return re.sub(r"[ \(\)'\"`\[\]\t\r\n]", "", sqltext).lower()
+
+
+def _compare_computed_default(
+ autogen_context: AutogenContext,
+ alter_column_op: AlterColumnOp,
+ schema: Optional[str],
+ tname: str,
+ cname: str,
+ conn_col: Column[Any],
+ metadata_col: Column[Any],
+) -> PriorityDispatchResult:
+
+ metadata_default = metadata_col.server_default
+ conn_col_default = conn_col.server_default
+ if conn_col_default is None and metadata_default is None:
+ return PriorityDispatchResult.CONTINUE
+
+ if sqla_compat._server_default_is_computed(
+ conn_col_default
+ ) and not sqla_compat._server_default_is_computed(metadata_default):
+ _warn_computed_not_supported(tname, cname)
+ return PriorityDispatchResult.STOP
+
+ if not sqla_compat._server_default_is_computed(metadata_default):
+ return PriorityDispatchResult.CONTINUE
+
+ rendered_metadata_default = str(
+ cast(sa_schema.Computed, metadata_col.server_default).sqltext.compile(
+ dialect=autogen_context.dialect,
+ compile_kwargs={"literal_binds": True},
+ )
+ )
+
+ # since we cannot change computed columns, we do only a crude comparison
+ # here where we try to eliminate syntactical differences in order to
+ # get a minimal comparison just to emit a warning.
+
+ rendered_metadata_default = _normalize_computed_default(
+ rendered_metadata_default
+ )
+
+ if isinstance(conn_col.server_default, sa_schema.Computed):
+ rendered_conn_default = str(
+ conn_col.server_default.sqltext.compile(
+ dialect=autogen_context.dialect,
+ compile_kwargs={"literal_binds": True},
+ )
+ )
+ rendered_conn_default = _normalize_computed_default(
+ rendered_conn_default
+ )
+ else:
+ rendered_conn_default = ""
+
+ if rendered_metadata_default != rendered_conn_default:
+ _warn_computed_not_supported(tname, cname)
+
+ return PriorityDispatchResult.STOP
+
+
+def _warn_computed_not_supported(tname: str, cname: str) -> None:
+ util.warn("Computed default on %s.%s cannot be modified" % (tname, cname))
+
+
+def _compare_identity_default(
+ autogen_context: AutogenContext,
+ alter_column_op: AlterColumnOp,
+ schema: Optional[str],
+ tname: Union[quoted_name, str],
+ cname: Union[quoted_name, str],
+ conn_col: Column[Any],
+ metadata_col: Column[Any],
+ skip: Sequence[str] = (
+ "order",
+ "on_null",
+ "oracle_order",
+ "oracle_on_null",
+ ),
+) -> PriorityDispatchResult:
+
+ metadata_default = metadata_col.server_default
+ conn_col_default = conn_col.server_default
+ if (
+ conn_col_default is None
+ and metadata_default is None
+ or not sqla_compat._server_default_is_identity(
+ metadata_default, conn_col_default
+ )
+ ):
+ return PriorityDispatchResult.CONTINUE
+
+ assert isinstance(
+ metadata_col.server_default,
+ (sa_schema.Identity, sa_schema.Sequence, NoneType),
+ )
+ assert isinstance(
+ conn_col.server_default,
+ (sa_schema.Identity, sa_schema.Sequence, NoneType),
+ )
+
+ impl = autogen_context.migration_context.impl
+ diff, _, is_alter = impl._compare_identity_default( # type: ignore[no-untyped-call] # noqa: E501
+ metadata_col.server_default, conn_col.server_default
+ )
+
+ if is_alter:
+ alter_column_op.modify_server_default = metadata_default
+ if diff:
+ log.info(
+ "Detected server default on column '%s.%s': "
+ "identity options attributes %s",
+ tname,
+ cname,
+ sorted(diff),
+ )
+
+ return PriorityDispatchResult.STOP
+
+ return PriorityDispatchResult.CONTINUE
+
+
+def _user_compare_server_default(
+ autogen_context: AutogenContext,
+ alter_column_op: AlterColumnOp,
+ schema: Optional[str],
+ tname: Union[quoted_name, str],
+ cname: Union[quoted_name, str],
+ conn_col: Column[Any],
+ metadata_col: Column[Any],
+) -> PriorityDispatchResult:
+
+ metadata_default = metadata_col.server_default
+ conn_col_default = conn_col.server_default
+ if conn_col_default is None and metadata_default is None:
+ return PriorityDispatchResult.CONTINUE
+
+ alter_column_op.existing_server_default = conn_col_default
+
+ migration_context = autogen_context.migration_context
+
+ if migration_context._user_compare_server_default is False:
+ return PriorityDispatchResult.STOP
+
+ if not callable(migration_context._user_compare_server_default):
+ return PriorityDispatchResult.CONTINUE
+
+ rendered_metadata_default = _render_server_default_for_compare(
+ metadata_default, autogen_context
+ )
+ rendered_conn_default = (
+ cast(Any, conn_col_default).arg.text if conn_col_default else None
+ )
+
+ is_diff = migration_context._user_compare_server_default(
+ migration_context,
+ conn_col,
+ metadata_col,
+ rendered_conn_default,
+ metadata_col.server_default,
+ rendered_metadata_default,
+ )
+ if is_diff:
+ alter_column_op.modify_server_default = metadata_default
+ log.info(
+ "User defined function %s detected "
+ "server default on column '%s.%s'",
+ migration_context._user_compare_server_default,
+ tname,
+ cname,
+ )
+ return PriorityDispatchResult.STOP
+ elif is_diff is False:
+ # if user compare server_default returns False and not None,
+ # it means "dont do any more server_default comparison"
+ return PriorityDispatchResult.STOP
+
+ return PriorityDispatchResult.CONTINUE
+
+
+def _dialect_impl_compare_server_default(
+ autogen_context: AutogenContext,
+ alter_column_op: AlterColumnOp,
+ schema: Optional[str],
+ tname: Union[quoted_name, str],
+ cname: Union[quoted_name, str],
+ conn_col: Column[Any],
+ metadata_col: Column[Any],
+) -> PriorityDispatchResult:
+ """use dialect.impl.compare_server_default.
+
+ This would in theory not be needed. however we dont know if any
+ third party libraries haven't made their own alembic dialect and
+ implemented this method.
+
+ """
+ metadata_default = metadata_col.server_default
+ conn_col_default = conn_col.server_default
+ if conn_col_default is None and metadata_default is None:
+ return PriorityDispatchResult.CONTINUE
+
+ # this is already done by _user_compare_server_default,
+ # but doing it here also for unit tests that want to call
+ # _dialect_impl_compare_server_default directly
+ alter_column_op.existing_server_default = conn_col_default
+
+ if not isinstance(
+ metadata_default, (DefaultClause, NoneType)
+ ) or not isinstance(conn_col_default, (DefaultClause, NoneType)):
+ return PriorityDispatchResult.CONTINUE
+
+ migration_context = autogen_context.migration_context
+
+ rendered_metadata_default = _render_server_default_for_compare(
+ metadata_default, autogen_context
+ )
+ rendered_conn_default = (
+ cast(Any, conn_col_default).arg.text if conn_col_default else None
+ )
+
+ is_diff = migration_context.impl.compare_server_default( # type: ignore[no-untyped-call] # noqa: E501
+ conn_col,
+ metadata_col,
+ rendered_metadata_default,
+ rendered_conn_default,
+ )
+ if is_diff:
+ alter_column_op.modify_server_default = metadata_default
+ log.info(
+ "Dialect impl %s detected server default on column '%s.%s'",
+ migration_context.impl,
+ tname,
+ cname,
+ )
+ return PriorityDispatchResult.STOP
+ return PriorityDispatchResult.CONTINUE
+
+
+def _setup_autoincrement(
+ autogen_context: AutogenContext,
+ alter_column_op: AlterColumnOp,
+ schema: Optional[str],
+ tname: Union[quoted_name, str],
+ cname: quoted_name,
+ conn_col: Column[Any],
+ metadata_col: Column[Any],
+) -> PriorityDispatchResult:
+ if metadata_col.table._autoincrement_column is metadata_col:
+ alter_column_op.kw["autoincrement"] = True
+ elif metadata_col.autoincrement is True:
+ alter_column_op.kw["autoincrement"] = True
+ elif metadata_col.autoincrement is False:
+ alter_column_op.kw["autoincrement"] = False
+
+ return PriorityDispatchResult.CONTINUE
+
+
+def setup(plugin: Plugin) -> None:
+ plugin.add_autogenerate_comparator(
+ _user_compare_server_default,
+ "column",
+ "server_default",
+ priority=DispatchPriority.FIRST,
+ )
+ plugin.add_autogenerate_comparator(
+ _compare_computed_default,
+ "column",
+ "server_default",
+ )
+
+ plugin.add_autogenerate_comparator(
+ _compare_identity_default,
+ "column",
+ "server_default",
+ )
+
+ plugin.add_autogenerate_comparator(
+ _setup_autoincrement,
+ "column",
+ "server_default",
+ )
+ plugin.add_autogenerate_comparator(
+ _dialect_impl_compare_server_default,
+ "column",
+ "server_default",
+ priority=DispatchPriority.LAST,
+ )
diff --git a/libs/alembic/autogenerate/compare/tables.py b/libs/alembic/autogenerate/compare/tables.py
new file mode 100644
index 0000000000..31eddc6b59
--- /dev/null
+++ b/libs/alembic/autogenerate/compare/tables.py
@@ -0,0 +1,316 @@
+# mypy: allow-untyped-calls
+
+from __future__ import annotations
+
+import contextlib
+import logging
+from typing import Iterator
+from typing import Optional
+from typing import Set
+from typing import Tuple
+from typing import TYPE_CHECKING
+from typing import Union
+
+from sqlalchemy import event
+from sqlalchemy import schema as sa_schema
+from sqlalchemy.util import OrderedSet
+
+from .util import _InspectorConv
+from ...operations import ops
+from ...util import PriorityDispatchResult
+
+if TYPE_CHECKING:
+ from sqlalchemy.engine.reflection import Inspector
+ from sqlalchemy.sql.elements import quoted_name
+ from sqlalchemy.sql.schema import Table
+
+ from ...autogenerate.api import AutogenContext
+ from ...operations.ops import ModifyTableOps
+ from ...operations.ops import UpgradeOps
+ from ...runtime.plugins import Plugin
+
+
+log = logging.getLogger(__name__)
+
+
+def _autogen_for_tables(
+ autogen_context: AutogenContext,
+ upgrade_ops: UpgradeOps,
+ schemas: Set[Optional[str]],
+) -> PriorityDispatchResult:
+ inspector = autogen_context.inspector
+
+ conn_table_names: Set[Tuple[Optional[str], str]] = set()
+
+ version_table_schema = (
+ autogen_context.migration_context.version_table_schema
+ )
+ version_table = autogen_context.migration_context.version_table
+
+ for schema_name in schemas:
+ tables = available = set(inspector.get_table_names(schema=schema_name))
+ if schema_name == version_table_schema:
+ tables = tables.difference(
+ [autogen_context.migration_context.version_table]
+ )
+
+ tablenames = [
+ tname
+ for tname in tables
+ if autogen_context.run_name_filters(
+ tname, "table", {"schema_name": schema_name}
+ )
+ ]
+
+ conn_table_names.update((schema_name, tname) for tname in tablenames)
+
+ inspector = autogen_context.inspector
+ insp = _InspectorConv(inspector)
+ insp.pre_cache_tables(schema_name, tablenames, available)
+
+ metadata_table_names = OrderedSet(
+ [(table.schema, table.name) for table in autogen_context.sorted_tables]
+ ).difference([(version_table_schema, version_table)])
+
+ _compare_tables(
+ conn_table_names,
+ metadata_table_names,
+ inspector,
+ upgrade_ops,
+ autogen_context,
+ )
+
+ return PriorityDispatchResult.CONTINUE
+
+
+def _compare_tables(
+ conn_table_names: set[tuple[str | None, str]],
+ metadata_table_names: set[tuple[str | None, str]],
+ inspector: Inspector,
+ upgrade_ops: UpgradeOps,
+ autogen_context: AutogenContext,
+) -> None:
+ default_schema = inspector.bind.dialect.default_schema_name
+
+ # tables coming from the connection will not have "schema"
+ # set if it matches default_schema_name; so we need a list
+ # of table names from local metadata that also have "None" if schema
+ # == default_schema_name. Most setups will be like this anyway but
+ # some are not (see #170)
+ metadata_table_names_no_dflt_schema = OrderedSet(
+ [
+ (schema if schema != default_schema else None, tname)
+ for schema, tname in metadata_table_names
+ ]
+ )
+
+ # to adjust for the MetaData collection storing the tables either
+ # as "schemaname.tablename" or just "tablename", create a new lookup
+ # which will match the "non-default-schema" keys to the Table object.
+ tname_to_table = {
+ no_dflt_schema: autogen_context.table_key_to_table[
+ sa_schema._get_table_key(tname, schema)
+ ]
+ for no_dflt_schema, (schema, tname) in zip(
+ metadata_table_names_no_dflt_schema, metadata_table_names
+ )
+ }
+ metadata_table_names = metadata_table_names_no_dflt_schema
+
+ for s, tname in metadata_table_names.difference(conn_table_names):
+ name = "%s.%s" % (s, tname) if s else tname
+ metadata_table = tname_to_table[(s, tname)]
+ if autogen_context.run_object_filters(
+ metadata_table, tname, "table", False, None
+ ):
+ upgrade_ops.ops.append(
+ ops.CreateTableOp.from_table(metadata_table)
+ )
+ log.info("Detected added table %r", name)
+ modify_table_ops = ops.ModifyTableOps(tname, [], schema=s)
+
+ autogen_context.comparators.dispatch(
+ "table", qualifier=autogen_context.dialect.name
+ )(
+ autogen_context,
+ modify_table_ops,
+ s,
+ tname,
+ None,
+ metadata_table,
+ )
+ if not modify_table_ops.is_empty():
+ upgrade_ops.ops.append(modify_table_ops)
+
+ removal_metadata = sa_schema.MetaData()
+ for s, tname in conn_table_names.difference(metadata_table_names):
+ name = sa_schema._get_table_key(tname, s)
+
+ # a name might be present already if a previous reflection pulled
+ # this table in via foreign key constraint
+ exists = name in removal_metadata.tables
+ t = sa_schema.Table(tname, removal_metadata, schema=s)
+
+ if not exists:
+ event.listen(
+ t,
+ "column_reflect",
+ # fmt: off
+ autogen_context.migration_context.impl.
+ _compat_autogen_column_reflect
+ (inspector),
+ # fmt: on
+ )
+ _InspectorConv(inspector).reflect_table(t)
+ if autogen_context.run_object_filters(t, tname, "table", True, None):
+ modify_table_ops = ops.ModifyTableOps(tname, [], schema=s)
+
+ autogen_context.comparators.dispatch(
+ "table", qualifier=autogen_context.dialect.name
+ )(autogen_context, modify_table_ops, s, tname, t, None)
+ if not modify_table_ops.is_empty():
+ upgrade_ops.ops.append(modify_table_ops)
+
+ upgrade_ops.ops.append(ops.DropTableOp.from_table(t))
+ log.info("Detected removed table %r", name)
+
+ existing_tables = conn_table_names.intersection(metadata_table_names)
+
+ existing_metadata = sa_schema.MetaData()
+ conn_column_info = {}
+ for s, tname in existing_tables:
+ name = sa_schema._get_table_key(tname, s)
+ exists = name in existing_metadata.tables
+
+ # a name might be present already if a previous reflection pulled
+ # this table in via foreign key constraint
+ t = sa_schema.Table(tname, existing_metadata, schema=s)
+ if not exists:
+ event.listen(
+ t,
+ "column_reflect",
+ # fmt: off
+ autogen_context.migration_context.impl.
+ _compat_autogen_column_reflect(inspector),
+ # fmt: on
+ )
+ _InspectorConv(inspector).reflect_table(t)
+
+ conn_column_info[(s, tname)] = t
+
+ for s, tname in sorted(existing_tables, key=lambda x: (x[0] or "", x[1])):
+ s = s or None
+ name = "%s.%s" % (s, tname) if s else tname
+ metadata_table = tname_to_table[(s, tname)]
+ conn_table = existing_metadata.tables[name]
+
+ if autogen_context.run_object_filters(
+ metadata_table, tname, "table", False, conn_table
+ ):
+ modify_table_ops = ops.ModifyTableOps(tname, [], schema=s)
+ with _compare_columns(
+ s,
+ tname,
+ conn_table,
+ metadata_table,
+ modify_table_ops,
+ autogen_context,
+ inspector,
+ ):
+ autogen_context.comparators.dispatch(
+ "table", qualifier=autogen_context.dialect.name
+ )(
+ autogen_context,
+ modify_table_ops,
+ s,
+ tname,
+ conn_table,
+ metadata_table,
+ )
+
+ if not modify_table_ops.is_empty():
+ upgrade_ops.ops.append(modify_table_ops)
+
+
+@contextlib.contextmanager
+def _compare_columns(
+ schema: Optional[str],
+ tname: Union[quoted_name, str],
+ conn_table: Table,
+ metadata_table: Table,
+ modify_table_ops: ModifyTableOps,
+ autogen_context: AutogenContext,
+ inspector: Inspector,
+) -> Iterator[None]:
+ name = "%s.%s" % (schema, tname) if schema else tname
+ metadata_col_names = OrderedSet(
+ c.name for c in metadata_table.c if not c.system
+ )
+ metadata_cols_by_name = {
+ c.name: c for c in metadata_table.c if not c.system
+ }
+
+ conn_col_names = {
+ c.name: c
+ for c in conn_table.c
+ if autogen_context.run_name_filters(
+ c.name, "column", {"table_name": tname, "schema_name": schema}
+ )
+ }
+
+ for cname in metadata_col_names.difference(conn_col_names):
+ if autogen_context.run_object_filters(
+ metadata_cols_by_name[cname], cname, "column", False, None
+ ):
+ modify_table_ops.ops.append(
+ ops.AddColumnOp.from_column_and_tablename(
+ schema, tname, metadata_cols_by_name[cname]
+ )
+ )
+ log.info("Detected added column '%s.%s'", name, cname)
+
+ for colname in metadata_col_names.intersection(conn_col_names):
+ metadata_col = metadata_cols_by_name[colname]
+ conn_col = conn_table.c[colname]
+ if not autogen_context.run_object_filters(
+ metadata_col, colname, "column", False, conn_col
+ ):
+ continue
+ alter_column_op = ops.AlterColumnOp(tname, colname, schema=schema)
+
+ autogen_context.comparators.dispatch(
+ "column", qualifier=autogen_context.dialect.name
+ )(
+ autogen_context,
+ alter_column_op,
+ schema,
+ tname,
+ colname,
+ conn_col,
+ metadata_col,
+ )
+
+ if alter_column_op.has_changes():
+ modify_table_ops.ops.append(alter_column_op)
+
+ yield
+
+ for cname in set(conn_col_names).difference(metadata_col_names):
+ if autogen_context.run_object_filters(
+ conn_table.c[cname], cname, "column", True, None
+ ):
+ modify_table_ops.ops.append(
+ ops.DropColumnOp.from_column_and_tablename(
+ schema, tname, conn_table.c[cname]
+ )
+ )
+ log.info("Detected removed column '%s.%s'", name, cname)
+
+
+def setup(plugin: Plugin) -> None:
+
+ plugin.add_autogenerate_comparator(
+ _autogen_for_tables,
+ "schema",
+ "tables",
+ )
diff --git a/libs/alembic/autogenerate/compare/types.py b/libs/alembic/autogenerate/compare/types.py
new file mode 100644
index 0000000000..1d5d160a35
--- /dev/null
+++ b/libs/alembic/autogenerate/compare/types.py
@@ -0,0 +1,147 @@
+from __future__ import annotations
+
+import logging
+from typing import Any
+from typing import Optional
+from typing import TYPE_CHECKING
+from typing import Union
+
+from sqlalchemy import types as sqltypes
+
+from ...util import DispatchPriority
+from ...util import PriorityDispatchResult
+
+if TYPE_CHECKING:
+ from sqlalchemy.sql.elements import quoted_name
+ from sqlalchemy.sql.schema import Column
+
+ from ...autogenerate.api import AutogenContext
+ from ...operations.ops import AlterColumnOp
+ from ...runtime.plugins import Plugin
+
+
+log = logging.getLogger(__name__)
+
+
+def _compare_type_setup(
+ alter_column_op: AlterColumnOp,
+ tname: Union[quoted_name, str],
+ cname: Union[quoted_name, str],
+ conn_col: Column[Any],
+ metadata_col: Column[Any],
+) -> bool:
+
+ conn_type = conn_col.type
+ alter_column_op.existing_type = conn_type
+ metadata_type = metadata_col.type
+ if conn_type._type_affinity is sqltypes.NullType:
+ log.info(
+ "Couldn't determine database type for column '%s.%s'",
+ tname,
+ cname,
+ )
+ return False
+ if metadata_type._type_affinity is sqltypes.NullType:
+ log.info(
+ "Column '%s.%s' has no type within the model; can't compare",
+ tname,
+ cname,
+ )
+ return False
+
+ return True
+
+
+def _user_compare_type(
+ autogen_context: AutogenContext,
+ alter_column_op: AlterColumnOp,
+ schema: Optional[str],
+ tname: Union[quoted_name, str],
+ cname: Union[quoted_name, str],
+ conn_col: Column[Any],
+ metadata_col: Column[Any],
+) -> PriorityDispatchResult:
+
+ migration_context = autogen_context.migration_context
+
+ if migration_context._user_compare_type is False:
+ return PriorityDispatchResult.STOP
+
+ if not _compare_type_setup(
+ alter_column_op, tname, cname, conn_col, metadata_col
+ ):
+ return PriorityDispatchResult.CONTINUE
+
+ if not callable(migration_context._user_compare_type):
+ return PriorityDispatchResult.CONTINUE
+
+ is_diff = migration_context._user_compare_type(
+ migration_context,
+ conn_col,
+ metadata_col,
+ conn_col.type,
+ metadata_col.type,
+ )
+ if is_diff:
+ alter_column_op.modify_type = metadata_col.type
+ log.info(
+ "Detected type change from %r to %r on '%s.%s'",
+ conn_col.type,
+ metadata_col.type,
+ tname,
+ cname,
+ )
+ return PriorityDispatchResult.STOP
+ elif is_diff is False:
+ # if user compare type returns False and not None,
+ # it means "dont do any more type comparison"
+ return PriorityDispatchResult.STOP
+
+ return PriorityDispatchResult.CONTINUE
+
+
+def _dialect_impl_compare_type(
+ autogen_context: AutogenContext,
+ alter_column_op: AlterColumnOp,
+ schema: Optional[str],
+ tname: Union[quoted_name, str],
+ cname: Union[quoted_name, str],
+ conn_col: Column[Any],
+ metadata_col: Column[Any],
+) -> PriorityDispatchResult:
+
+ if not _compare_type_setup(
+ alter_column_op, tname, cname, conn_col, metadata_col
+ ):
+ return PriorityDispatchResult.CONTINUE
+
+ migration_context = autogen_context.migration_context
+ is_diff = migration_context.impl.compare_type(conn_col, metadata_col)
+
+ if is_diff:
+ alter_column_op.modify_type = metadata_col.type
+ log.info(
+ "Detected type change from %r to %r on '%s.%s'",
+ conn_col.type,
+ metadata_col.type,
+ tname,
+ cname,
+ )
+ return PriorityDispatchResult.STOP
+
+ return PriorityDispatchResult.CONTINUE
+
+
+def setup(plugin: Plugin) -> None:
+ plugin.add_autogenerate_comparator(
+ _user_compare_type,
+ "column",
+ "types",
+ priority=DispatchPriority.FIRST,
+ )
+ plugin.add_autogenerate_comparator(
+ _dialect_impl_compare_type,
+ "column",
+ "types",
+ priority=DispatchPriority.LAST,
+ )
diff --git a/libs/alembic/autogenerate/compare/util.py b/libs/alembic/autogenerate/compare/util.py
new file mode 100644
index 0000000000..41829c0e0b
--- /dev/null
+++ b/libs/alembic/autogenerate/compare/util.py
@@ -0,0 +1,314 @@
+# mypy: allow-untyped-defs, allow-incomplete-defs, allow-untyped-calls
+# mypy: no-warn-return-any, allow-any-generics
+from __future__ import annotations
+
+from typing import Any
+from typing import cast
+from typing import Collection
+from typing import TYPE_CHECKING
+
+from sqlalchemy.sql.elements import conv
+from typing_extensions import Self
+
+from ...util import sqla_compat
+
+if TYPE_CHECKING:
+ from sqlalchemy import Table
+ from sqlalchemy.engine import Inspector
+ from sqlalchemy.engine.interfaces import ReflectedForeignKeyConstraint
+ from sqlalchemy.engine.interfaces import ReflectedIndex
+ from sqlalchemy.engine.interfaces import ReflectedUniqueConstraint
+ from sqlalchemy.engine.reflection import _ReflectionInfo
+
+_INSP_KEYS = (
+ "columns",
+ "pk_constraint",
+ "foreign_keys",
+ "indexes",
+ "unique_constraints",
+ "table_comment",
+ "check_constraints",
+ "table_options",
+)
+_CONSTRAINT_INSP_KEYS = (
+ "pk_constraint",
+ "foreign_keys",
+ "indexes",
+ "unique_constraints",
+ "check_constraints",
+)
+
+
+class _InspectorConv:
+ __slots__ = ("inspector",)
+
+ def __new__(cls, inspector: Inspector) -> Self:
+ obj: Any
+ if sqla_compat.sqla_2:
+ obj = object.__new__(_SQLA2InspectorConv)
+ _SQLA2InspectorConv.__init__(obj, inspector)
+ else:
+ obj = object.__new__(_LegacyInspectorConv)
+ _LegacyInspectorConv.__init__(obj, inspector)
+ return cast(Self, obj)
+
+ def __init__(self, inspector: Inspector):
+ self.inspector = inspector
+
+ def pre_cache_tables(
+ self,
+ schema: str | None,
+ tablenames: list[str],
+ all_available_tablenames: Collection[str],
+ ) -> None:
+ pass
+
+ def get_unique_constraints(
+ self, tname: str, schema: str | None
+ ) -> list[ReflectedUniqueConstraint]:
+ raise NotImplementedError()
+
+ def get_indexes(
+ self, tname: str, schema: str | None
+ ) -> list[ReflectedIndex]:
+ raise NotImplementedError()
+
+ def get_foreign_keys(
+ self, tname: str, schema: str | None
+ ) -> list[ReflectedForeignKeyConstraint]:
+ raise NotImplementedError()
+
+ def reflect_table(self, table: Table) -> None:
+ raise NotImplementedError()
+
+
+class _LegacyInspectorConv(_InspectorConv):
+
+ def _apply_reflectinfo_conv(self, consts):
+ if not consts:
+ return consts
+ for const in consts:
+ if const["name"] is not None and not isinstance(
+ const["name"], conv
+ ):
+ const["name"] = conv(const["name"])
+ return consts
+
+ def _apply_constraint_conv(self, consts):
+ if not consts:
+ return consts
+ for const in consts:
+ if const.name is not None and not isinstance(const.name, conv):
+ const.name = conv(const.name)
+ return consts
+
+ def get_indexes(
+ self, tname: str, schema: str | None
+ ) -> list[ReflectedIndex]:
+ return self._apply_reflectinfo_conv(
+ self.inspector.get_indexes(tname, schema=schema)
+ )
+
+ def get_unique_constraints(
+ self, tname: str, schema: str | None
+ ) -> list[ReflectedUniqueConstraint]:
+ return self._apply_reflectinfo_conv(
+ self.inspector.get_unique_constraints(tname, schema=schema)
+ )
+
+ def get_foreign_keys(
+ self, tname: str, schema: str | None
+ ) -> list[ReflectedForeignKeyConstraint]:
+ return self._apply_reflectinfo_conv(
+ self.inspector.get_foreign_keys(tname, schema=schema)
+ )
+
+ def reflect_table(self, table: Table) -> None:
+ self.inspector.reflect_table(table, include_columns=None)
+
+ self._apply_constraint_conv(table.constraints)
+ self._apply_constraint_conv(table.indexes)
+
+
+class _SQLA2InspectorConv(_InspectorConv):
+
+ def _pre_cache(
+ self,
+ schema: str | None,
+ tablenames: list[str],
+ all_available_tablenames: Collection[str],
+ info_key: str,
+ inspector_method: Any,
+ ) -> None:
+
+ if info_key in self.inspector.info_cache:
+ return
+
+ # heuristic vendored from SQLAlchemy 2.0
+ # if more than 50% of the tables in the db are in filter_names load all
+ # the tables, since it's most likely faster to avoid a filter on that
+ # many tables. also if a dialect doesnt have a "multi" method then
+ # return the filter names
+ if tablenames and all_available_tablenames and len(tablenames) > 100:
+ fraction = len(tablenames) / len(all_available_tablenames)
+ else:
+ fraction = None
+
+ if (
+ fraction is None
+ or fraction <= 0.5
+ or not self.inspector.dialect._overrides_default(
+ inspector_method.__name__
+ )
+ ):
+ optimized_filter_names = tablenames
+ else:
+ optimized_filter_names = None
+
+ try:
+ elements = inspector_method(
+ schema=schema, filter_names=optimized_filter_names
+ )
+ except NotImplementedError:
+ self.inspector.info_cache[info_key] = NotImplementedError
+ else:
+ self.inspector.info_cache[info_key] = elements
+
+ def _return_from_cache(
+ self,
+ tname: str,
+ schema: str | None,
+ info_key: str,
+ inspector_method: Any,
+ apply_constraint_conv: bool = False,
+ optional=True,
+ ) -> Any:
+ not_in_cache = object()
+
+ if info_key in self.inspector.info_cache:
+ cache = self.inspector.info_cache[info_key]
+ if cache is NotImplementedError:
+ if optional:
+ return {}
+ else:
+ # maintain NotImplementedError as alembic compare
+ # uses these to determine classes of construct that it
+ # should not compare to DB elements
+ raise NotImplementedError()
+
+ individual = cache.get((schema, tname), not_in_cache)
+
+ if individual is not not_in_cache:
+ if apply_constraint_conv and individual is not None:
+ return self._apply_reflectinfo_conv(individual)
+ else:
+ return individual
+
+ try:
+ data = inspector_method(tname, schema=schema)
+ except NotImplementedError:
+ if optional:
+ return {}
+ else:
+ raise
+
+ if apply_constraint_conv:
+ return self._apply_reflectinfo_conv(data)
+ else:
+ return data
+
+ def get_unique_constraints(
+ self, tname: str, schema: str | None
+ ) -> list[ReflectedUniqueConstraint]:
+ return self._return_from_cache(
+ tname,
+ schema,
+ "alembic_unique_constraints",
+ self.inspector.get_unique_constraints,
+ apply_constraint_conv=True,
+ optional=False,
+ )
+
+ def get_indexes(
+ self, tname: str, schema: str | None
+ ) -> list[ReflectedIndex]:
+ return self._return_from_cache(
+ tname,
+ schema,
+ "alembic_indexes",
+ self.inspector.get_indexes,
+ apply_constraint_conv=True,
+ optional=False,
+ )
+
+ def get_foreign_keys(
+ self, tname: str, schema: str | None
+ ) -> list[ReflectedForeignKeyConstraint]:
+ return self._return_from_cache(
+ tname,
+ schema,
+ "alembic_foreign_keys",
+ self.inspector.get_foreign_keys,
+ apply_constraint_conv=True,
+ )
+
+ def _apply_reflectinfo_conv(self, consts):
+ if not consts:
+ return consts
+ for const in consts if not isinstance(consts, dict) else [consts]:
+ if const["name"] is not None and not isinstance(
+ const["name"], conv
+ ):
+ const["name"] = conv(const["name"])
+ return consts
+
+ def pre_cache_tables(
+ self,
+ schema: str | None,
+ tablenames: list[str],
+ all_available_tablenames: Collection[str],
+ ) -> None:
+ for key in _INSP_KEYS:
+ keyname = f"alembic_{key}"
+ meth = getattr(self.inspector, f"get_multi_{key}")
+
+ self._pre_cache(
+ schema,
+ tablenames,
+ all_available_tablenames,
+ keyname,
+ meth,
+ )
+
+ def _make_reflection_info(
+ self, tname: str, schema: str | None
+ ) -> _ReflectionInfo:
+ from sqlalchemy.engine.reflection import _ReflectionInfo
+
+ table_key = (schema, tname)
+
+ return _ReflectionInfo(
+ unreflectable={},
+ **{
+ key: {
+ table_key: self._return_from_cache(
+ tname,
+ schema,
+ f"alembic_{key}",
+ getattr(self.inspector, f"get_{key}"),
+ apply_constraint_conv=(key in _CONSTRAINT_INSP_KEYS),
+ )
+ }
+ for key in _INSP_KEYS
+ },
+ )
+
+ def reflect_table(self, table: Table) -> None:
+ ri = self._make_reflection_info(table.name, table.schema)
+
+ self.inspector.reflect_table(
+ table,
+ include_columns=None,
+ resolve_fks=False,
+ _reflect_info=ri,
+ )
diff --git a/libs/alembic/autogenerate/render.py b/libs/alembic/autogenerate/render.py
index 38bdbfca26..7f32838df7 100644
--- a/libs/alembic/autogenerate/render.py
+++ b/libs/alembic/autogenerate/render.py
@@ -18,7 +18,9 @@
from sqlalchemy import schema as sa_schema
from sqlalchemy import sql
from sqlalchemy import types as sqltypes
+from sqlalchemy.sql.base import _DialectArgView
from sqlalchemy.sql.elements import conv
+from sqlalchemy.sql.elements import Label
from sqlalchemy.sql.elements import quoted_name
from .. import util
@@ -28,7 +30,8 @@
if TYPE_CHECKING:
from typing import Literal
- from sqlalchemy.sql.base import DialectKWArgs
+ from sqlalchemy import Computed
+ from sqlalchemy import Identity
from sqlalchemy.sql.elements import ColumnElement
from sqlalchemy.sql.elements import TextClause
from sqlalchemy.sql.schema import CheckConstraint
@@ -48,8 +51,6 @@
from alembic.config import Config
from alembic.operations.ops import MigrationScript
from alembic.operations.ops import ModifyTableOps
- from alembic.util.sqla_compat import Computed
- from alembic.util.sqla_compat import Identity
MAX_PYTHON_ARGS = 255
@@ -303,11 +304,11 @@ def _drop_table(autogen_context: AutogenContext, op: ops.DropTableOp) -> str:
def _render_dialect_kwargs_items(
- autogen_context: AutogenContext, item: DialectKWArgs
+ autogen_context: AutogenContext, dialect_kwargs: _DialectArgView
) -> list[str]:
return [
f"{key}={_render_potential_expr(val, autogen_context)}"
- for key, val in item.dialect_kwargs.items()
+ for key, val in dialect_kwargs.items()
]
@@ -330,7 +331,7 @@ def _add_index(autogen_context: AutogenContext, op: ops.CreateIndexOp) -> str:
assert index.table is not None
- opts = _render_dialect_kwargs_items(autogen_context, index)
+ opts = _render_dialect_kwargs_items(autogen_context, index.dialect_kwargs)
if op.if_not_exists is not None:
opts.append("if_not_exists=%r" % bool(op.if_not_exists))
text = tmpl % {
@@ -364,7 +365,7 @@ def _drop_index(autogen_context: AutogenContext, op: ops.DropIndexOp) -> str:
"%(prefix)sdrop_index(%(name)r, "
"table_name=%(table_name)r%(schema)s%(kwargs)s)"
)
- opts = _render_dialect_kwargs_items(autogen_context, index)
+ opts = _render_dialect_kwargs_items(autogen_context, index.dialect_kwargs)
if op.if_exists is not None:
opts.append("if_exists=%r" % bool(op.if_exists))
text = tmpl % {
@@ -388,6 +389,7 @@ def _add_unique_constraint(
def _add_fk_constraint(
autogen_context: AutogenContext, op: ops.CreateForeignKeyOp
) -> str:
+ constraint = op.to_constraint()
args = [repr(_render_gen_name(autogen_context, op.constraint_name))]
if not autogen_context._has_batch:
args.append(repr(_ident(op.source_table)))
@@ -417,9 +419,16 @@ def _add_fk_constraint(
if value is not None:
args.append("%s=%r" % (k, value))
- return "%(prefix)screate_foreign_key(%(args)s)" % {
+ dialect_kwargs = _render_dialect_kwargs_items(
+ autogen_context, constraint.dialect_kwargs
+ )
+
+ return "%(prefix)screate_foreign_key(%(args)s%(dialect_kwargs)s)" % {
"prefix": _alembic_autogenerate_prefix(autogen_context),
"args": ", ".join(args),
+ "dialect_kwargs": (
+ ", " + ", ".join(dialect_kwargs) if dialect_kwargs else ""
+ ),
}
@@ -441,7 +450,7 @@ def _drop_constraint(
name = _render_gen_name(autogen_context, op.constraint_name)
schema = _ident(op.schema) if op.schema else None
type_ = _ident(op.constraint_type) if op.constraint_type else None
-
+ if_exists = op.if_exists
params_strs = []
params_strs.append(repr(name))
if not autogen_context._has_batch:
@@ -450,32 +459,47 @@ def _drop_constraint(
params_strs.append(f"schema={schema!r}")
if type_ is not None:
params_strs.append(f"type_={type_!r}")
+ if if_exists is not None:
+ params_strs.append(f"if_exists={if_exists}")
return f"{prefix}drop_constraint({', '.join(params_strs)})"
@renderers.dispatch_for(ops.AddColumnOp)
def _add_column(autogen_context: AutogenContext, op: ops.AddColumnOp) -> str:
- schema, tname, column = op.schema, op.table_name, op.column
+ schema, tname, column, if_not_exists = (
+ op.schema,
+ op.table_name,
+ op.column,
+ op.if_not_exists,
+ )
if autogen_context._has_batch:
template = "%(prefix)sadd_column(%(column)s)"
else:
template = "%(prefix)sadd_column(%(tname)r, %(column)s"
if schema:
template += ", schema=%(schema)r"
+ if if_not_exists is not None:
+ template += ", if_not_exists=%(if_not_exists)r"
template += ")"
text = template % {
"prefix": _alembic_autogenerate_prefix(autogen_context),
"tname": tname,
"column": _render_column(column, autogen_context),
"schema": schema,
+ "if_not_exists": if_not_exists,
}
return text
@renderers.dispatch_for(ops.DropColumnOp)
def _drop_column(autogen_context: AutogenContext, op: ops.DropColumnOp) -> str:
- schema, tname, column_name = op.schema, op.table_name, op.column_name
+ schema, tname, column_name, if_exists = (
+ op.schema,
+ op.table_name,
+ op.column_name,
+ op.if_exists,
+ )
if autogen_context._has_batch:
template = "%(prefix)sdrop_column(%(cname)r)"
@@ -483,6 +507,8 @@ def _drop_column(autogen_context: AutogenContext, op: ops.DropColumnOp) -> str:
template = "%(prefix)sdrop_column(%(tname)r, %(cname)r"
if schema:
template += ", schema=%(schema)r"
+ if if_exists is not None:
+ template += ", if_exists=%(if_exists)r"
template += ")"
text = template % {
@@ -490,6 +516,7 @@ def _drop_column(autogen_context: AutogenContext, op: ops.DropColumnOp) -> str:
"tname": _ident(tname),
"cname": _ident(column_name),
"schema": _ident(schema),
+ "if_exists": if_exists,
}
return text
@@ -504,6 +531,7 @@ def _alter_column(
type_ = op.modify_type
nullable = op.modify_nullable
comment = op.modify_comment
+ newname = op.modify_name
autoincrement = op.kw.get("autoincrement", None)
existing_type = op.existing_type
existing_nullable = op.existing_nullable
@@ -532,6 +560,8 @@ def _alter_column(
rendered = _render_server_default(server_default, autogen_context)
text += ",\n%sserver_default=%s" % (indent, rendered)
+ if newname is not None:
+ text += ",\n%snew_column_name=%r" % (indent, newname)
if type_ is not None:
text += ",\n%stype_=%s" % (indent, _repr_type(type_, autogen_context))
if nullable is not None:
@@ -584,23 +614,28 @@ def _render_potential_expr(
value: Any,
autogen_context: AutogenContext,
*,
- wrap_in_text: bool = True,
+ wrap_in_element: bool = True,
is_server_default: bool = False,
is_index: bool = False,
) -> str:
if isinstance(value, sql.ClauseElement):
- if wrap_in_text:
- template = "%(prefix)stext(%(sql)r)"
+ sql_text = autogen_context.migration_context.impl.render_ddl_sql_expr(
+ value, is_server_default=is_server_default, is_index=is_index
+ )
+ if wrap_in_element:
+ prefix = _sqlalchemy_autogenerate_prefix(autogen_context)
+ element = "literal_column" if is_index else "text"
+ value_str = f"{prefix}{element}({sql_text!r})"
+ if (
+ is_index
+ and isinstance(value, Label)
+ and type(value.name) is str
+ ):
+ return value_str + f".label({value.name!r})"
+ else:
+ return value_str
else:
- template = "%(sql)r"
-
- return template % {
- "prefix": _sqlalchemy_autogenerate_prefix(autogen_context),
- "sql": autogen_context.migration_context.impl.render_ddl_sql_expr(
- value, is_server_default=is_server_default, is_index=is_index
- ),
- }
-
+ return repr(sql_text)
else:
return repr(value)
@@ -628,16 +663,18 @@ def _uq_constraint(
has_batch = autogen_context._has_batch
if constraint.deferrable:
- opts.append(("deferrable", str(constraint.deferrable)))
+ opts.append(("deferrable", constraint.deferrable))
if constraint.initially:
- opts.append(("initially", str(constraint.initially)))
+ opts.append(("initially", constraint.initially))
if not has_batch and alter and constraint.table.schema:
opts.append(("schema", _ident(constraint.table.schema)))
if not alter and constraint.name:
opts.append(
("name", _render_gen_name(autogen_context, constraint.name))
)
- dialect_options = _render_dialect_kwargs_items(autogen_context, constraint)
+ dialect_options = _render_dialect_kwargs_items(
+ autogen_context, constraint.dialect_kwargs
+ )
if alter:
args = [repr(_render_gen_name(autogen_context, constraint.name))]
@@ -741,7 +778,7 @@ def _render_column(
+ [
"%s=%s"
% (key, _render_potential_expr(val, autogen_context))
- for key, val in sqla_compat._column_kwargs(column).items()
+ for key, val in column.kwargs.items()
]
)
),
@@ -776,6 +813,8 @@ def _render_server_default(
return _render_potential_expr(
default.arg, autogen_context, is_server_default=True
)
+ elif isinstance(default, sa_schema.FetchedValue):
+ return _render_fetched_value(autogen_context)
if isinstance(default, str) and repr_:
default = repr(re.sub(r"^'|'$", "", default))
@@ -787,7 +826,7 @@ def _render_computed(
computed: Computed, autogen_context: AutogenContext
) -> str:
text = _render_potential_expr(
- computed.sqltext, autogen_context, wrap_in_text=False
+ computed.sqltext, autogen_context, wrap_in_element=False
)
kwargs = {}
@@ -813,6 +852,12 @@ def _render_identity(
}
+def _render_fetched_value(autogen_context: AutogenContext) -> str:
+ return "%(prefix)sFetchedValue()" % {
+ "prefix": _sqlalchemy_autogenerate_prefix(autogen_context),
+ }
+
+
def _repr_type(
type_: TypeEngine,
autogen_context: AutogenContext,
@@ -831,7 +876,10 @@ def _repr_type(
mod = type(type_).__module__
imports = autogen_context.imports
- if mod.startswith("sqlalchemy.dialects"):
+
+ if not _skip_variants and sqla_compat._type_has_variants(type_):
+ return _render_Variant_type(type_, autogen_context)
+ elif mod.startswith("sqlalchemy.dialects"):
match = re.match(r"sqlalchemy\.dialects\.(\w+)", mod)
assert match is not None
dname = match.group(1)
@@ -843,8 +891,6 @@ def _repr_type(
return "%s.%r" % (dname, type_)
elif impl_rt:
return impl_rt
- elif not _skip_variants and sqla_compat._type_has_variants(type_):
- return _render_Variant_type(type_, autogen_context)
elif mod.startswith("sqlalchemy."):
if "_render_%s_type" % type_.__visit_name__ in globals():
fn = globals()["_render_%s_type" % type_.__visit_name__]
@@ -962,7 +1008,7 @@ def _render_primary_key(
def _fk_colspec(
fk: ForeignKey,
metadata_schema: Optional[str],
- namespace_metadata: MetaData,
+ namespace_metadata: Optional[MetaData],
) -> str:
"""Implement a 'safe' version of ForeignKey._get_colspec() that
won't fail if the remote table can't be resolved.
@@ -986,7 +1032,10 @@ def _fk_colspec(
# the FK constraint needs to be rendered in terms of the column
# name.
- if table_fullname in namespace_metadata.tables:
+ if (
+ namespace_metadata is not None
+ and table_fullname in namespace_metadata.tables
+ ):
col = namespace_metadata.tables[table_fullname].c.get(colname)
if col is not None:
colname = _ident(col.name) # type: ignore[assignment]
@@ -1017,7 +1066,7 @@ def _populate_render_fk_opts(
def _render_foreign_key(
constraint: ForeignKeyConstraint,
autogen_context: AutogenContext,
- namespace_metadata: MetaData,
+ namespace_metadata: Optional[MetaData],
) -> Optional[str]:
rendered = _user_defined_render("foreign_key", constraint, autogen_context)
if rendered is not False:
@@ -1031,7 +1080,9 @@ def _render_foreign_key(
_populate_render_fk_opts(constraint, opts)
- apply_metadata_schema = namespace_metadata.schema
+ apply_metadata_schema = (
+ namespace_metadata.schema if namespace_metadata is not None else None
+ )
return (
"%(prefix)sForeignKeyConstraint([%(cols)s], "
"[%(refcols)s], %(args)s)"
@@ -1100,7 +1151,7 @@ def _render_check_constraint(
else ""
),
"sqltext": _render_potential_expr(
- constraint.sqltext, autogen_context, wrap_in_text=False
+ constraint.sqltext, autogen_context, wrap_in_element=False
),
}
@@ -1112,7 +1163,10 @@ def _execute_sql(autogen_context: AutogenContext, op: ops.ExecuteSQLOp) -> str:
"Autogenerate rendering of SQL Expression language constructs "
"not supported here; please use a plain SQL string"
)
- return "op.execute(%r)" % op.sqltext
+ return "{prefix}execute({sqltext!r})".format(
+ prefix=_alembic_autogenerate_prefix(autogen_context),
+ sqltext=op.sqltext,
+ )
renderers = default_renderers.branch()
diff --git a/libs/alembic/autogenerate/rewriter.py b/libs/alembic/autogenerate/rewriter.py
index 8994dcf823..1d44b5c340 100644
--- a/libs/alembic/autogenerate/rewriter.py
+++ b/libs/alembic/autogenerate/rewriter.py
@@ -177,7 +177,7 @@ def _traverse_script(
)
upgrade_ops_list.append(ret[0])
- directive.upgrade_ops = upgrade_ops_list # type: ignore
+ directive.upgrade_ops = upgrade_ops_list
downgrade_ops_list: List[DowngradeOps] = []
for downgrade_ops in directive.downgrade_ops_list:
@@ -187,7 +187,7 @@ def _traverse_script(
"Can only return single object for DowngradeOps traverse"
)
downgrade_ops_list.append(ret[0])
- directive.downgrade_ops = downgrade_ops_list # type: ignore
+ directive.downgrade_ops = downgrade_ops_list
@_traverse.dispatch_for(ops.OpContainer)
def _traverse_op_container(
diff --git a/libs/alembic/command.py b/libs/alembic/command.py
index 89c12354a6..4897c0d9c2 100644
--- a/libs/alembic/command.py
+++ b/libs/alembic/command.py
@@ -3,6 +3,7 @@
from __future__ import annotations
import os
+import pathlib
from typing import List
from typing import Optional
from typing import TYPE_CHECKING
@@ -12,6 +13,7 @@
from . import util
from .runtime.environment import EnvironmentContext
from .script import ScriptDirectory
+from .util import compat
if TYPE_CHECKING:
from alembic.config import Config
@@ -28,12 +30,10 @@ def list_templates(config: Config) -> None:
"""
config.print_stdout("Available templates:\n")
- for tempname in os.listdir(config.get_template_directory()):
- with open(
- os.path.join(config.get_template_directory(), tempname, "README")
- ) as readme:
+ for tempname in config._get_template_path().iterdir():
+ with (tempname / "README").open() as readme:
synopsis = next(readme).rstrip()
- config.print_stdout("%s - %s", tempname, synopsis)
+ config.print_stdout("%s - %s", tempname.name, synopsis)
config.print_stdout("\nTemplates are used via the 'init' command, e.g.:")
config.print_stdout("\n alembic init --template generic ./scripts")
@@ -59,65 +59,136 @@ def init(
"""
- if os.access(directory, os.F_OK) and os.listdir(directory):
+ directory_path = pathlib.Path(directory)
+ if directory_path.exists() and list(directory_path.iterdir()):
raise util.CommandError(
- "Directory %s already exists and is not empty" % directory
+ "Directory %s already exists and is not empty" % directory_path
)
- template_dir = os.path.join(config.get_template_directory(), template)
- if not os.access(template_dir, os.F_OK):
- raise util.CommandError("No such template %r" % template)
+ template_path = config._get_template_path() / template
- if not os.access(directory, os.F_OK):
+ if not template_path.exists():
+ raise util.CommandError(f"No such template {template_path}")
+
+ # left as os.access() to suit unit test mocking
+ if not os.access(directory_path, os.F_OK):
with util.status(
- f"Creating directory {os.path.abspath(directory)!r}",
+ f"Creating directory {directory_path.absolute()}",
**config.messaging_opts,
):
- os.makedirs(directory)
+ os.makedirs(directory_path)
- versions = os.path.join(directory, "versions")
+ versions = directory_path / "versions"
with util.status(
- f"Creating directory {os.path.abspath(versions)!r}",
+ f"Creating directory {versions.absolute()}",
**config.messaging_opts,
):
os.makedirs(versions)
- script = ScriptDirectory(directory)
+ if not directory_path.is_absolute():
+ # for non-absolute path, state config file in .ini / pyproject
+ # as relative to the %(here)s token, which is where the config
+ # file itself would be
+
+ if config._config_file_path is not None:
+ rel_dir = compat.path_relative_to(
+ directory_path.absolute(),
+ config._config_file_path.absolute().parent,
+ walk_up=True,
+ )
+ ini_script_location_directory = ("%(here)s" / rel_dir).as_posix()
+ if config._toml_file_path is not None:
+ rel_dir = compat.path_relative_to(
+ directory_path.absolute(),
+ config._toml_file_path.absolute().parent,
+ walk_up=True,
+ )
+ toml_script_location_directory = ("%(here)s" / rel_dir).as_posix()
+
+ else:
+ ini_script_location_directory = directory_path.as_posix()
+ toml_script_location_directory = directory_path.as_posix()
+
+ script = ScriptDirectory(directory_path)
+
+ has_toml = False
- config_file: str | None = None
- for file_ in os.listdir(template_dir):
- file_path = os.path.join(template_dir, file_)
+ config_file: pathlib.Path | None = None
+
+ for file_path in template_path.iterdir():
+ file_ = file_path.name
if file_ == "alembic.ini.mako":
assert config.config_file_name is not None
- config_file = os.path.abspath(config.config_file_name)
- if os.access(config_file, os.F_OK):
+ config_file = pathlib.Path(config.config_file_name).absolute()
+ if config_file.exists():
util.msg(
- f"File {config_file!r} already exists, skipping",
+ f"File {config_file} already exists, skipping",
**config.messaging_opts,
)
else:
script._generate_template(
- file_path, config_file, script_location=directory
+ file_path,
+ config_file,
+ script_location=ini_script_location_directory,
+ )
+ elif file_ == "pyproject.toml.mako":
+ has_toml = True
+ assert config._toml_file_path is not None
+ toml_path = config._toml_file_path.absolute()
+
+ if toml_path.exists():
+ # left as open() to suit unit test mocking
+ with open(toml_path, "rb") as f:
+ toml_data = compat.tomllib.load(f)
+ if "tool" in toml_data and "alembic" in toml_data["tool"]:
+
+ util.msg(
+ f"File {toml_path} already exists "
+ "and already has a [tool.alembic] section, "
+ "skipping",
+ )
+ continue
+ script._append_template(
+ file_path,
+ toml_path,
+ script_location=toml_script_location_directory,
+ )
+ else:
+ script._generate_template(
+ file_path,
+ toml_path,
+ script_location=toml_script_location_directory,
)
- elif os.path.isfile(file_path):
- output_file = os.path.join(directory, file_)
+
+ elif file_path.is_file():
+ output_file = directory_path / file_
script._copy_file(file_path, output_file)
if package:
for path in [
- os.path.join(os.path.abspath(directory), "__init__.py"),
- os.path.join(os.path.abspath(versions), "__init__.py"),
+ directory_path.absolute() / "__init__.py",
+ versions.absolute() / "__init__.py",
]:
- with util.status(f"Adding {path!r}", **config.messaging_opts):
+ with util.status(f"Adding {path!s}", **config.messaging_opts):
+ # left as open() to suit unit test mocking
with open(path, "w"):
pass
assert config_file is not None
- util.msg(
- "Please edit configuration/connection/logging "
- f"settings in {config_file!r} before proceeding.",
- **config.messaging_opts,
- )
+
+ if has_toml:
+ util.msg(
+ f"Please edit configuration settings in {toml_path} and "
+ "configuration/connection/logging "
+ f"settings in {config_file} before proceeding.",
+ **config.messaging_opts,
+ )
+ else:
+ util.msg(
+ "Please edit configuration/connection/logging "
+ f"settings in {config_file} before proceeding.",
+ **config.messaging_opts,
+ )
def revision(
@@ -128,7 +199,7 @@ def revision(
head: str = "head",
splice: bool = False,
branch_label: Optional[_RevIdType] = None,
- version_path: Optional[str] = None,
+ version_path: Union[str, os.PathLike[str], None] = None,
rev_id: Optional[str] = None,
depends_on: Optional[str] = None,
process_revision_directives: Optional[ProcessRevisionDirectiveFn] = None,
@@ -198,7 +269,9 @@ def revision(
process_revision_directives=process_revision_directives,
)
- environment = util.asbool(config.get_main_option("revision_environment"))
+ environment = util.asbool(
+ config.get_alembic_option("revision_environment")
+ )
if autogenerate:
environment = True
@@ -298,7 +371,9 @@ def retrieve_migrations(rev, context):
if diffs:
raise util.AutogenerateDiffsDetected(
- f"New upgrade operations detected: {diffs}"
+ f"New upgrade operations detected: {diffs}",
+ revision_context=revision_context,
+ diffs=diffs,
)
else:
config.print_stdout("No new upgrade operations detected.")
@@ -336,7 +411,9 @@ def merge(
# e.g. multiple databases
}
- environment = util.asbool(config.get_main_option("revision_environment"))
+ environment = util.asbool(
+ config.get_alembic_option("revision_environment")
+ )
if environment:
@@ -509,7 +586,7 @@ def history(
base = head = None
environment = (
- util.asbool(config.get_main_option("revision_environment"))
+ util.asbool(config.get_alembic_option("revision_environment"))
or indicate_current
)
@@ -604,11 +681,18 @@ def branches(config: Config, verbose: bool = False) -> None:
)
-def current(config: Config, verbose: bool = False) -> None:
+def current(
+ config: Config, check_heads: bool = False, verbose: bool = False
+) -> None:
"""Display the current revision for a database.
:param config: a :class:`.Config` instance.
+ :param check_heads: Check if all head revisions are applied to the
+ database. Raises :class:`.DatabaseNotAtHead` if this is not the case.
+
+ .. versionadded:: 1.17.1
+
:param verbose: output in verbose mode.
"""
@@ -621,6 +705,12 @@ def display_version(rev, context):
"Current revision(s) for %s:",
util.obfuscate_url_pw(context.connection.engine.url),
)
+ if check_heads and (
+ set(context.get_current_heads()) != set(script.get_heads())
+ ):
+ raise util.DatabaseNotAtHead(
+ "Database is not on all head revisions"
+ )
for rev in script.get_all_current(rev):
config.print_stdout(rev.cmd_format(verbose))
diff --git a/libs/alembic/config.py b/libs/alembic/config.py
index 2c52e7cd13..121a4459cd 100644
--- a/libs/alembic/config.py
+++ b/libs/alembic/config.py
@@ -4,7 +4,10 @@
from argparse import Namespace
from configparser import ConfigParser
import inspect
+import logging
import os
+from pathlib import Path
+import re
import sys
from typing import Any
from typing import cast
@@ -12,6 +15,7 @@
from typing import Mapping
from typing import Optional
from typing import overload
+from typing import Protocol
from typing import Sequence
from typing import TextIO
from typing import Union
@@ -22,6 +26,10 @@
from . import command
from . import util
from .util import compat
+from .util.pyfiles import _preserving_path_as_str
+
+
+log = logging.getLogger(__name__)
class Config:
@@ -71,7 +79,20 @@ class Config:
alembic_cfg.attributes['connection'] = connection
command.upgrade(alembic_cfg, "head")
- :param file\_: name of the .ini file to open.
+ :param file\_: name of the .ini file to open if an ``alembic.ini`` is
+ to be used. This should refer to the ``alembic.ini`` file, either as
+ a filename or a full path to the file. This filename if passed must refer
+ to an **ini file in ConfigParser format** only.
+
+ :param toml\_file: name of the pyproject.toml file to open if a
+ ``pyproject.toml`` file is to be used. This should refer to the
+ ``pyproject.toml`` file, either as a filename or a full path to the file.
+ This file must be in toml format. Both :paramref:`.Config.file\_` and
+ :paramref:`.Config.toml\_file` may be passed simultaneously, or
+ exclusively.
+
+ .. versionadded:: 1.16.0
+
:param ini_section: name of the main Alembic section within the
.ini file
:param output_buffer: optional file-like input buffer which
@@ -81,12 +102,13 @@ class Config:
Defaults to ``sys.stdout``.
:param config_args: A dictionary of keys and values that will be used
- for substitution in the alembic config file. The dictionary as given
- is **copied** to a new one, stored locally as the attribute
- ``.config_args``. When the :attr:`.Config.file_config` attribute is
- first invoked, the replacement variable ``here`` will be added to this
- dictionary before the dictionary is passed to ``ConfigParser()``
- to parse the .ini file.
+ for substitution in the alembic config file, as well as the pyproject.toml
+ file, depending on which / both are used. The dictionary as given is
+ **copied** to two new, independent dictionaries, stored locally under the
+ attributes ``.config_args`` and ``.toml_args``. Both of these
+ dictionaries will also be populated with the replacement variable
+ ``%(here)s``, which refers to the location of the .ini and/or .toml file
+ as appropriate.
:param attributes: optional dictionary of arbitrary Python keys/values,
which will be populated into the :attr:`.Config.attributes` dictionary.
@@ -100,6 +122,7 @@ class Config:
def __init__(
self,
file_: Union[str, os.PathLike[str], None] = None,
+ toml_file: Union[str, os.PathLike[str], None] = None,
ini_section: str = "alembic",
output_buffer: Optional[TextIO] = None,
stdout: TextIO = sys.stdout,
@@ -108,12 +131,18 @@ def __init__(
attributes: Optional[Dict[str, Any]] = None,
) -> None:
"""Construct a new :class:`.Config`"""
- self.config_file_name = file_
+ self.config_file_name = (
+ _preserving_path_as_str(file_) if file_ else None
+ )
+ self.toml_file_name = (
+ _preserving_path_as_str(toml_file) if toml_file else None
+ )
self.config_ini_section = ini_section
self.output_buffer = output_buffer
self.stdout = stdout
self.cmd_opts = cmd_opts
self.config_args = dict(config_args)
+ self.toml_args = dict(config_args)
if attributes:
self.attributes.update(attributes)
@@ -129,9 +158,28 @@ def __init__(
"""
- config_file_name: Union[str, os.PathLike[str], None] = None
+ config_file_name: Optional[str] = None
"""Filesystem path to the .ini file in use."""
+ toml_file_name: Optional[str] = None
+ """Filesystem path to the pyproject.toml file in use.
+
+ .. versionadded:: 1.16.0
+
+ """
+
+ @property
+ def _config_file_path(self) -> Optional[Path]:
+ if self.config_file_name is None:
+ return None
+ return Path(self.config_file_name)
+
+ @property
+ def _toml_file_path(self) -> Optional[Path]:
+ if self.toml_file_name is None:
+ return None
+ return Path(self.toml_file_name)
+
config_ini_section: str = None # type:ignore[assignment]
"""Name of the config file section to read basic configuration
from. Defaults to ``alembic``, that is the ``[alembic]`` section
@@ -187,25 +235,55 @@ def print_stdout(self, text: str, *arg: Any) -> None:
def file_config(self) -> ConfigParser:
"""Return the underlying ``ConfigParser`` object.
- Direct access to the .ini file is available here,
+ Dir*-ect access to the .ini file is available here,
though the :meth:`.Config.get_section` and
:meth:`.Config.get_main_option`
methods provide a possibly simpler interface.
"""
- if self.config_file_name:
- here = os.path.abspath(os.path.dirname(self.config_file_name))
+ if self._config_file_path:
+ here = self._config_file_path.absolute().parent
else:
- here = ""
- self.config_args["here"] = here
+ here = Path()
+ self.config_args["here"] = here.as_posix()
file_config = ConfigParser(self.config_args)
- if self.config_file_name:
- compat.read_config_parser(file_config, [self.config_file_name])
+
+ verbose = getattr(self.cmd_opts, "verbose", False)
+ if self._config_file_path:
+ compat.read_config_parser(file_config, [self._config_file_path])
+ if verbose:
+ log.info(
+ "Loading config from file: %s", self._config_file_path
+ )
else:
file_config.add_section(self.config_ini_section)
+ if verbose:
+ log.info(
+ "No config file provided; using in-memory default config"
+ )
return file_config
+ @util.memoized_property
+ def toml_alembic_config(self) -> Mapping[str, Any]:
+ """Return a dictionary of the [tool.alembic] section from
+ pyproject.toml"""
+
+ if self._toml_file_path and self._toml_file_path.exists():
+
+ here = self._toml_file_path.absolute().parent
+ self.toml_args["here"] = here.as_posix()
+
+ with open(self._toml_file_path, "rb") as f:
+ toml_data = compat.tomllib.load(f)
+ data = toml_data.get("tool", {}).get("alembic", {})
+ if not isinstance(data, dict):
+ raise util.CommandError("Incorrect TOML format")
+ return data
+
+ else:
+ return {}
+
def get_template_directory(self) -> str:
"""Return the directory where Alembic setup templates are found.
@@ -215,8 +293,19 @@ def get_template_directory(self) -> str:
"""
import alembic
- package_dir = os.path.abspath(os.path.dirname(alembic.__file__))
- return os.path.join(package_dir, "templates")
+ package_dir = Path(alembic.__file__).absolute().parent
+ return str(package_dir / "templates")
+
+ def _get_template_path(self) -> Path:
+ """Return the directory where Alembic setup templates are found.
+
+ This method is used by the alembic ``init`` and ``list_templates``
+ commands.
+
+ .. versionadded:: 1.16.0
+
+ """
+ return Path(self.get_template_directory())
@overload
def get_section(
@@ -278,6 +367,12 @@ def set_section_option(self, section: str, name: str, value: str) -> None:
The value here will override whatever was in the .ini
file.
+ Does **NOT** consume from the pyproject.toml file.
+
+ .. seealso::
+
+ :meth:`.Config.get_alembic_option` - includes pyproject support
+
:param section: name of the section
:param name: name of the value
@@ -326,9 +421,106 @@ def get_main_option(
section, unless the ``-n/--name`` flag were used to
indicate a different section.
+ Does **NOT** consume from the pyproject.toml file.
+
+ .. seealso::
+
+ :meth:`.Config.get_alembic_option` - includes pyproject support
+
"""
return self.get_section_option(self.config_ini_section, name, default)
+ @overload
+ def get_alembic_option(self, name: str, default: str) -> str: ...
+
+ @overload
+ def get_alembic_option(
+ self, name: str, default: Optional[str] = None
+ ) -> Optional[str]: ...
+
+ def get_alembic_option(
+ self, name: str, default: Optional[str] = None
+ ) -> Union[
+ None, str, list[str], dict[str, str], list[dict[str, str]], int
+ ]:
+ """Return an option from the "[alembic]" or "[tool.alembic]" section
+ of the configparser-parsed .ini file (e.g. ``alembic.ini``) or
+ toml-parsed ``pyproject.toml`` file.
+
+ The value returned is expected to be None, string, list of strings,
+ or dictionary of strings. Within each type of string value, the
+ ``%(here)s`` token is substituted out with the absolute path of the
+ ``pyproject.toml`` file, as are other tokens which are extracted from
+ the :paramref:`.Config.config_args` dictionary.
+
+ Searches always prioritize the configparser namespace first, before
+ searching in the toml namespace.
+
+ If Alembic was run using the ``-n/--name`` flag to indicate an
+ alternate main section name, this is taken into account **only** for
+ the configparser-parsed .ini file. The section name in toml is always
+ ``[tool.alembic]``.
+
+
+ .. versionadded:: 1.16.0
+
+ """
+
+ if self.file_config.has_option(self.config_ini_section, name):
+ return self.file_config.get(self.config_ini_section, name)
+ else:
+ return self._get_toml_config_value(name, default=default)
+
+ def get_alembic_boolean_option(self, name: str) -> bool:
+ if self.file_config.has_option(self.config_ini_section, name):
+ return (
+ self.file_config.get(self.config_ini_section, name) == "true"
+ )
+ else:
+ value = self.toml_alembic_config.get(name, False)
+ if not isinstance(value, bool):
+ raise util.CommandError(
+ f"boolean value expected for TOML parameter {name!r}"
+ )
+ return value
+
+ def _get_toml_config_value(
+ self, name: str, default: Optional[Any] = None
+ ) -> Union[
+ None, str, list[str], dict[str, str], list[dict[str, str]], int
+ ]:
+ USE_DEFAULT = object()
+ value: Union[None, str, list[str], dict[str, str], int] = (
+ self.toml_alembic_config.get(name, USE_DEFAULT)
+ )
+ if value is USE_DEFAULT:
+ return default
+ if value is not None:
+ if isinstance(value, str):
+ value = value % (self.toml_args)
+ elif isinstance(value, list):
+ if value and isinstance(value[0], dict):
+ value = [
+ {k: v % (self.toml_args) for k, v in dv.items()}
+ for dv in value
+ ]
+ else:
+ value = cast(
+ "list[str]", [v % (self.toml_args) for v in value]
+ )
+ elif isinstance(value, dict):
+ value = cast(
+ "dict[str, str]",
+ {k: v % (self.toml_args) for k, v in value.items()},
+ )
+ elif isinstance(value, int):
+ return value
+ else:
+ raise util.CommandError(
+ f"unsupported TOML value type for key: {name!r}"
+ )
+ return value
+
@util.memoized_property
def messaging_opts(self) -> MessagingOptions:
"""The messaging options."""
@@ -339,181 +531,324 @@ def messaging_opts(self) -> MessagingOptions:
),
)
+ def _get_file_separator_char(self, *names: str) -> Optional[str]:
+ for name in names:
+ separator = self.get_main_option(name)
+ if separator is not None:
+ break
+ else:
+ return None
+
+ split_on_path = {
+ "space": " ",
+ "newline": "\n",
+ "os": os.pathsep,
+ ":": ":",
+ ";": ";",
+ }
+
+ try:
+ sep = split_on_path[separator]
+ except KeyError as ke:
+ raise ValueError(
+ "'%s' is not a valid value for %s; "
+ "expected 'space', 'newline', 'os', ':', ';'"
+ % (separator, name)
+ ) from ke
+ else:
+ if name == "version_path_separator":
+ util.warn_deprecated(
+ "The version_path_separator configuration parameter "
+ "is deprecated; please use path_separator"
+ )
+ return sep
+
+ def get_version_locations_list(self) -> Optional[list[str]]:
+
+ version_locations_str = self.file_config.get(
+ self.config_ini_section, "version_locations", fallback=None
+ )
+
+ if version_locations_str:
+ split_char = self._get_file_separator_char(
+ "path_separator", "version_path_separator"
+ )
+
+ if split_char is None:
+
+ # legacy behaviour for backwards compatibility
+ util.warn_deprecated(
+ "No path_separator found in configuration; "
+ "falling back to legacy splitting on spaces/commas "
+ "for version_locations. Consider adding "
+ "path_separator=os to Alembic config."
+ )
+
+ _split_on_space_comma = re.compile(r", *|(?: +)")
+ return _split_on_space_comma.split(version_locations_str)
+ else:
+ return [
+ x.strip()
+ for x in version_locations_str.split(split_char)
+ if x
+ ]
+ else:
+ return cast(
+ "list[str]",
+ self._get_toml_config_value("version_locations", None),
+ )
+
+ def get_prepend_sys_paths_list(self) -> Optional[list[str]]:
+ prepend_sys_path_str = self.file_config.get(
+ self.config_ini_section, "prepend_sys_path", fallback=None
+ )
+
+ if prepend_sys_path_str:
+ split_char = self._get_file_separator_char("path_separator")
+
+ if split_char is None:
+
+ # legacy behaviour for backwards compatibility
+ util.warn_deprecated(
+ "No path_separator found in configuration; "
+ "falling back to legacy splitting on spaces, commas, "
+ "and colons for prepend_sys_path. Consider adding "
+ "path_separator=os to Alembic config."
+ )
+
+ _split_on_space_comma_colon = re.compile(r", *|(?: +)|\:")
+ return _split_on_space_comma_colon.split(prepend_sys_path_str)
+ else:
+ return [
+ x.strip()
+ for x in prepend_sys_path_str.split(split_char)
+ if x
+ ]
+ else:
+ return cast(
+ "list[str]",
+ self._get_toml_config_value("prepend_sys_path", None),
+ )
+
+ def get_hooks_list(self) -> list[PostWriteHookConfig]:
+
+ hooks: list[PostWriteHookConfig] = []
+
+ if not self.file_config.has_section("post_write_hooks"):
+ toml_hook_config = cast(
+ "list[dict[str, str]]",
+ self._get_toml_config_value("post_write_hooks", []),
+ )
+ for cfg in toml_hook_config:
+ opts = dict(cfg)
+ opts["_hook_name"] = opts.pop("name")
+ hooks.append(opts)
+
+ else:
+ _split_on_space_comma = re.compile(r", *|(?: +)")
+ ini_hook_config = self.get_section("post_write_hooks", {})
+ names = _split_on_space_comma.split(
+ ini_hook_config.get("hooks", "")
+ )
+
+ for name in names:
+ if not name:
+ continue
+ opts = {
+ key[len(name) + 1 :]: ini_hook_config[key]
+ for key in ini_hook_config
+ if key.startswith(name + ".")
+ }
+
+ opts["_hook_name"] = name
+ hooks.append(opts)
+
+ return hooks
+
+
+PostWriteHookConfig = Mapping[str, str]
+
class MessagingOptions(TypedDict, total=False):
quiet: bool
+class CommandFunction(Protocol):
+ """A function that may be registered in the CLI as an alembic command.
+ It must be a named function and it must accept a :class:`.Config` object
+ as the first argument.
+
+ .. versionadded:: 1.15.3
+
+ """
+
+ __name__: str
+
+ def __call__(self, config: Config, *args: Any, **kwargs: Any) -> Any: ...
+
+
class CommandLine:
+ """Provides the command line interface to Alembic."""
+
def __init__(self, prog: Optional[str] = None) -> None:
self._generate_args(prog)
- def _generate_args(self, prog: Optional[str]) -> None:
- def add_options(
- fn: Any, parser: Any, positional: Any, kwargs: Any
- ) -> None:
- kwargs_opts = {
- "template": (
- "-t",
- "--template",
- dict(
- default="generic",
- type=str,
- help="Setup template for use with 'init'",
- ),
- ),
- "message": (
- "-m",
- "--message",
- dict(
- type=str, help="Message string to use with 'revision'"
- ),
- ),
- "sql": (
- "--sql",
- dict(
- action="store_true",
- help="Don't emit SQL to database - dump to "
- "standard output/file instead. See docs on "
- "offline mode.",
- ),
- ),
- "tag": (
- "--tag",
- dict(
- type=str,
- help="Arbitrary 'tag' name - can be used by "
- "custom env.py scripts.",
- ),
- ),
- "head": (
- "--head",
- dict(
- type=str,
- help="Specify head revision or @head "
- "to base new revision on.",
- ),
- ),
- "splice": (
- "--splice",
- dict(
- action="store_true",
- help="Allow a non-head revision as the "
- "'head' to splice onto",
- ),
- ),
- "depends_on": (
- "--depends-on",
- dict(
- action="append",
- help="Specify one or more revision identifiers "
- "which this revision should depend on.",
- ),
- ),
- "rev_id": (
- "--rev-id",
- dict(
- type=str,
- help="Specify a hardcoded revision id instead of "
- "generating one",
- ),
- ),
- "version_path": (
- "--version-path",
- dict(
- type=str,
- help="Specify specific path from config for "
- "version file",
- ),
- ),
- "branch_label": (
- "--branch-label",
- dict(
- type=str,
- help="Specify a branch label to apply to the "
- "new revision",
- ),
- ),
- "verbose": (
- "-v",
- "--verbose",
- dict(action="store_true", help="Use more verbose output"),
- ),
- "resolve_dependencies": (
- "--resolve-dependencies",
- dict(
- action="store_true",
- help="Treat dependency versions as down revisions",
- ),
- ),
- "autogenerate": (
- "--autogenerate",
- dict(
- action="store_true",
- help="Populate revision script with candidate "
- "migration operations, based on comparison "
- "of database to model.",
- ),
- ),
- "rev_range": (
- "-r",
- "--rev-range",
- dict(
- action="store",
- help="Specify a revision range; "
- "format is [start]:[end]",
- ),
- ),
- "indicate_current": (
- "-i",
- "--indicate-current",
- dict(
- action="store_true",
- help="Indicate the current revision",
- ),
- ),
- "purge": (
- "--purge",
- dict(
- action="store_true",
- help="Unconditionally erase the version table "
- "before stamping",
- ),
- ),
- "package": (
- "--package",
- dict(
- action="store_true",
- help="Write empty __init__.py files to the "
- "environment and version locations",
- ),
+ _KWARGS_OPTS = {
+ "template": (
+ "-t",
+ "--template",
+ dict(
+ default="generic",
+ type=str,
+ help="Setup template for use with 'init'",
+ ),
+ ),
+ "message": (
+ "-m",
+ "--message",
+ dict(type=str, help="Message string to use with 'revision'"),
+ ),
+ "sql": (
+ "--sql",
+ dict(
+ action="store_true",
+ help="Don't emit SQL to database - dump to "
+ "standard output/file instead. See docs on "
+ "offline mode.",
+ ),
+ ),
+ "tag": (
+ "--tag",
+ dict(
+ type=str,
+ help="Arbitrary 'tag' name - can be used by "
+ "custom env.py scripts.",
+ ),
+ ),
+ "head": (
+ "--head",
+ dict(
+ type=str,
+ help="Specify head revision or @head "
+ "to base new revision on.",
+ ),
+ ),
+ "splice": (
+ "--splice",
+ dict(
+ action="store_true",
+ help="Allow a non-head revision as the 'head' to splice onto",
+ ),
+ ),
+ "depends_on": (
+ "--depends-on",
+ dict(
+ action="append",
+ help="Specify one or more revision identifiers "
+ "which this revision should depend on.",
+ ),
+ ),
+ "rev_id": (
+ "--rev-id",
+ dict(
+ type=str,
+ help="Specify a hardcoded revision id instead of "
+ "generating one",
+ ),
+ ),
+ "version_path": (
+ "--version-path",
+ dict(
+ type=str,
+ help="Specify specific path from config for version file",
+ ),
+ ),
+ "branch_label": (
+ "--branch-label",
+ dict(
+ type=str,
+ help="Specify a branch label to apply to the new revision",
+ ),
+ ),
+ "verbose": (
+ "-v",
+ "--verbose",
+ dict(action="store_true", help="Use more verbose output"),
+ ),
+ "resolve_dependencies": (
+ "--resolve-dependencies",
+ dict(
+ action="store_true",
+ help="Treat dependency versions as down revisions",
+ ),
+ ),
+ "autogenerate": (
+ "--autogenerate",
+ dict(
+ action="store_true",
+ help="Populate revision script with candidate "
+ "migration operations, based on comparison "
+ "of database to model.",
+ ),
+ ),
+ "rev_range": (
+ "-r",
+ "--rev-range",
+ dict(
+ action="store",
+ help="Specify a revision range; format is [start]:[end]",
+ ),
+ ),
+ "indicate_current": (
+ "-i",
+ "--indicate-current",
+ dict(
+ action="store_true",
+ help="Indicate the current revision",
+ ),
+ ),
+ "purge": (
+ "--purge",
+ dict(
+ action="store_true",
+ help="Unconditionally erase the version table before stamping",
+ ),
+ ),
+ "package": (
+ "--package",
+ dict(
+ action="store_true",
+ help="Write empty __init__.py files to the "
+ "environment and version locations",
+ ),
+ ),
+ "check_heads": (
+ "-c",
+ "--check-heads",
+ dict(
+ action="store_true",
+ help=(
+ "Check if all head revisions are applied to the database. "
+ "Exit with an error code if this is not the case."
),
- }
- positional_help = {
- "directory": "location of scripts directory",
- "revision": "revision identifier",
- "revisions": "one or more revisions, or 'heads' for all heads",
- }
- for arg in kwargs:
- if arg in kwargs_opts:
- args = kwargs_opts[arg]
- args, kw = args[0:-1], args[-1]
- parser.add_argument(*args, **kw)
-
- for arg in positional:
- if (
- arg == "revisions"
- or fn in positional_translations
- and positional_translations[fn][arg] == "revisions"
- ):
- subparser.add_argument(
- "revisions",
- nargs="+",
- help=positional_help.get("revisions"),
- )
- else:
- subparser.add_argument(arg, help=positional_help.get(arg))
+ ),
+ ),
+ }
+ _POSITIONAL_OPTS = {
+ "directory": dict(help="location of scripts directory"),
+ "revision": dict(
+ help="revision identifier",
+ ),
+ "revisions": dict(
+ nargs="+",
+ help="one or more revisions, or 'heads' for all heads",
+ ),
+ }
+ _POSITIONAL_TRANSLATIONS: dict[Any, dict[str, str]] = {
+ command.stamp: {"revision": "revisions"}
+ }
+ def _generate_args(self, prog: Optional[str]) -> None:
parser = ArgumentParser(prog=prog)
parser.add_argument(
@@ -522,17 +857,19 @@ def add_options(
parser.add_argument(
"-c",
"--config",
- type=str,
- default=os.environ.get("ALEMBIC_CONFIG", "alembic.ini"),
+ action="append",
help="Alternate config file; defaults to value of "
- 'ALEMBIC_CONFIG environment variable, or "alembic.ini"',
+ 'ALEMBIC_CONFIG environment variable, or "alembic.ini". '
+ "May also refer to pyproject.toml file. May be specified twice "
+ "to reference both files separately",
)
parser.add_argument(
"-n",
"--name",
type=str,
default="alembic",
- help="Name of section in .ini file to " "use for Alembic config",
+ help="Name of section in .ini file to use for Alembic config "
+ "(only applies to configparser config, not toml)",
)
parser.add_argument(
"-x",
@@ -552,50 +889,81 @@ def add_options(
action="store_true",
help="Do not log to std output.",
)
- subparsers = parser.add_subparsers()
-
- positional_translations: Dict[Any, Any] = {
- command.stamp: {"revision": "revisions"}
- }
- for fn in [getattr(command, n) for n in dir(command)]:
+ self.subparsers = parser.add_subparsers()
+ alembic_commands = (
+ cast(CommandFunction, fn)
+ for fn in (getattr(command, name) for name in dir(command))
if (
inspect.isfunction(fn)
and fn.__name__[0] != "_"
and fn.__module__ == "alembic.command"
- ):
- spec = compat.inspect_getfullargspec(fn)
- if spec[3] is not None:
- positional = spec[0][1 : -len(spec[3])]
- kwarg = spec[0][-len(spec[3]) :]
- else:
- positional = spec[0][1:]
- kwarg = []
+ )
+ )
- if fn in positional_translations:
- positional = [
- positional_translations[fn].get(name, name)
- for name in positional
- ]
+ for fn in alembic_commands:
+ self.register_command(fn)
- # parse first line(s) of helptext without a line break
- help_ = fn.__doc__
- if help_:
- help_text = []
- for line in help_.split("\n"):
- if not line.strip():
- break
- else:
- help_text.append(line.strip())
- else:
- help_text = []
- subparser = subparsers.add_parser(
- fn.__name__, help=" ".join(help_text)
- )
- add_options(fn, subparser, positional, kwarg)
- subparser.set_defaults(cmd=(fn, positional, kwarg))
self.parser = parser
+ def register_command(self, fn: CommandFunction) -> None:
+ """Registers a function as a CLI subcommand. The subcommand name
+ matches the function name, the arguments are extracted from the
+ signature and the help text is read from the docstring.
+
+ .. versionadded:: 1.15.3
+
+ .. seealso::
+
+ :ref:`custom_commandline`
+ """
+
+ positional, kwarg, help_text = self._inspect_function(fn)
+
+ subparser = self.subparsers.add_parser(fn.__name__, help=help_text)
+ subparser.set_defaults(cmd=(fn, positional, kwarg))
+
+ for arg in kwarg:
+ if arg in self._KWARGS_OPTS:
+ kwarg_opt = self._KWARGS_OPTS[arg]
+ args, opts = kwarg_opt[0:-1], kwarg_opt[-1]
+ subparser.add_argument(*args, **opts) # type:ignore
+
+ for arg in positional:
+ opts = self._POSITIONAL_OPTS.get(arg, {})
+ subparser.add_argument(arg, **opts) # type:ignore
+
+ def _inspect_function(self, fn: CommandFunction) -> tuple[Any, Any, str]:
+ spec = compat.inspect_getfullargspec(fn)
+ if spec[3] is not None:
+ positional = spec[0][1 : -len(spec[3])]
+ kwarg = spec[0][-len(spec[3]) :]
+ else:
+ positional = spec[0][1:]
+ kwarg = []
+
+ if fn in self._POSITIONAL_TRANSLATIONS:
+ positional = [
+ self._POSITIONAL_TRANSLATIONS[fn].get(name, name)
+ for name in positional
+ ]
+
+ # parse first line(s) of helptext without a line break
+ help_ = fn.__doc__
+ if help_:
+ help_lines = []
+ for line in help_.split("\n"):
+ if not line.strip():
+ break
+ else:
+ help_lines.append(line.strip())
+ else:
+ help_lines = []
+
+ help_text = " ".join(help_lines)
+
+ return positional, kwarg, help_text
+
def run_cmd(self, config: Config, options: Namespace) -> None:
fn, positional, kwarg = options.cmd
@@ -611,15 +979,58 @@ def run_cmd(self, config: Config, options: Namespace) -> None:
else:
util.err(str(e), **config.messaging_opts)
+ def _inis_from_config(self, options: Namespace) -> tuple[str, str]:
+ names = options.config
+
+ alembic_config_env = os.environ.get("ALEMBIC_CONFIG")
+ if (
+ alembic_config_env
+ and os.path.basename(alembic_config_env) == "pyproject.toml"
+ ):
+ default_pyproject_toml = alembic_config_env
+ default_alembic_config = "alembic.ini"
+ elif alembic_config_env:
+ default_pyproject_toml = "pyproject.toml"
+ default_alembic_config = alembic_config_env
+ else:
+ default_alembic_config = "alembic.ini"
+ default_pyproject_toml = "pyproject.toml"
+
+ if not names:
+ return default_pyproject_toml, default_alembic_config
+
+ toml = ini = None
+
+ for name in names:
+ if os.path.basename(name) == "pyproject.toml":
+ if toml is not None:
+ raise util.CommandError(
+ "pyproject.toml indicated more than once"
+ )
+ toml = name
+ else:
+ if ini is not None:
+ raise util.CommandError(
+ "only one ini file may be indicated"
+ )
+ ini = name
+
+ return toml if toml else default_pyproject_toml, (
+ ini if ini else default_alembic_config
+ )
+
def main(self, argv: Optional[Sequence[str]] = None) -> None:
+ """Executes the command line with the provided arguments."""
options = self.parser.parse_args(argv)
if not hasattr(options, "cmd"):
# see http://bugs.python.org/issue9253, argparse
# behavior changed incompatibly in py3.3
self.parser.error("too few arguments")
else:
+ toml, ini = self._inis_from_config(options)
cfg = Config(
- file_=options.config,
+ file_=ini,
+ toml_file=toml,
ini_section=options.name,
cmd_opts=options,
)
diff --git a/libs/alembic/context.pyi b/libs/alembic/context.pyi
index 80619fb24f..6045d8b3da 100644
--- a/libs/alembic/context.pyi
+++ b/libs/alembic/context.pyi
@@ -5,7 +5,6 @@ from __future__ import annotations
from typing import Any
from typing import Callable
from typing import Collection
-from typing import ContextManager
from typing import Dict
from typing import Iterable
from typing import List
@@ -20,6 +19,8 @@ from typing import Tuple
from typing import TYPE_CHECKING
from typing import Union
+from typing_extensions import ContextManager
+
if TYPE_CHECKING:
from sqlalchemy.engine.base import Connection
from sqlalchemy.engine.url import URL
@@ -40,7 +41,9 @@ if TYPE_CHECKING:
### end imports ###
-def begin_transaction() -> Union[_ProxyTransaction, ContextManager[None]]:
+def begin_transaction() -> (
+ Union[_ProxyTransaction, ContextManager[None, Optional[bool]]]
+):
"""Return a context manager that will
enclose an operation within a "transaction",
as defined by the environment's offline
@@ -200,6 +203,7 @@ def configure(
None,
]
] = None,
+ autogenerate_plugins: Optional[Sequence[str]] = None,
**kw: Any,
) -> None:
"""Configure a :class:`.MigrationContext` within this
@@ -619,6 +623,25 @@ def configure(
:paramref:`.command.revision.process_revision_directives`
+ :param autogenerate_plugins: A list of string names of "plugins" that
+ should participate in this autogenerate run. Defaults to the list
+ ``["alembic.autogenerate.*"]``, which indicates that Alembic's default
+ autogeneration plugins will be used.
+
+ See the section :ref:`plugins_autogenerate` for complete background
+ on how to use this parameter.
+
+ .. versionadded:: 1.18.0 Added a new plugin system for autogenerate
+ compare directives.
+
+ .. seealso::
+
+ :ref:`plugins_autogenerate` - background on enabling/disabling
+ autogenerate plugins
+
+ :ref:`alembic.plugins.toplevel` - Introduction and documentation
+ to the plugin system
+
Parameters specific to individual backends:
:param mssql_batch_separator: The "batch separator" which will
diff --git a/libs/alembic/ddl/base.py b/libs/alembic/ddl/base.py
index 6fbe95245c..30a3a15a39 100644
--- a/libs/alembic/ddl/base.py
+++ b/libs/alembic/ddl/base.py
@@ -4,6 +4,7 @@
from __future__ import annotations
import functools
+from typing import Any
from typing import Optional
from typing import TYPE_CHECKING
from typing import Union
@@ -14,7 +15,10 @@
from sqlalchemy.ext.compiler import compiles
from sqlalchemy.schema import Column
from sqlalchemy.schema import DDLElement
+from sqlalchemy.sql.elements import ColumnElement
from sqlalchemy.sql.elements import quoted_name
+from sqlalchemy.sql.elements import TextClause
+from sqlalchemy.sql.schema import FetchedValue
from ..util.sqla_compat import _columns_for_constraint # noqa
from ..util.sqla_compat import _find_columns # noqa
@@ -23,20 +27,16 @@
from ..util.sqla_compat import _table_for_constraint # noqa
if TYPE_CHECKING:
- from typing import Any
+ from sqlalchemy import Computed
+ from sqlalchemy import Identity
from sqlalchemy.sql.compiler import Compiled
from sqlalchemy.sql.compiler import DDLCompiler
- from sqlalchemy.sql.elements import TextClause
- from sqlalchemy.sql.functions import Function
- from sqlalchemy.sql.schema import FetchedValue
from sqlalchemy.sql.type_api import TypeEngine
from .impl import DefaultImpl
- from ..util.sqla_compat import Computed
- from ..util.sqla_compat import Identity
-_ServerDefault = Union["TextClause", "FetchedValue", "Function[Any]", str]
+_ServerDefaultType = Union[FetchedValue, str, TextClause, ColumnElement[Any]]
class AlterTable(DDLElement):
@@ -75,7 +75,7 @@ def __init__(
schema: Optional[str] = None,
existing_type: Optional[TypeEngine] = None,
existing_nullable: Optional[bool] = None,
- existing_server_default: Optional[_ServerDefault] = None,
+ existing_server_default: Optional[_ServerDefaultType] = None,
existing_comment: Optional[str] = None,
) -> None:
super().__init__(name, schema=schema)
@@ -119,7 +119,7 @@ def __init__(
self,
name: str,
column_name: str,
- default: Optional[_ServerDefault],
+ default: Optional[_ServerDefaultType],
**kw,
) -> None:
super().__init__(name, column_name, **kw)
@@ -154,17 +154,28 @@ def __init__(
name: str,
column: Column[Any],
schema: Optional[Union[quoted_name, str]] = None,
+ if_not_exists: Optional[bool] = None,
+ inline_references: Optional[bool] = None,
+ inline_primary_key: Optional[bool] = None,
) -> None:
super().__init__(name, schema=schema)
self.column = column
+ self.if_not_exists = if_not_exists
+ self.inline_references = inline_references
+ self.inline_primary_key = inline_primary_key
class DropColumn(AlterTable):
def __init__(
- self, name: str, column: Column[Any], schema: Optional[str] = None
+ self,
+ name: str,
+ column: Column[Any],
+ schema: Optional[str] = None,
+ if_exists: Optional[bool] = None,
) -> None:
super().__init__(name, schema=schema)
self.column = column
+ self.if_exists = if_exists
class ColumnComment(AlterColumn):
@@ -189,7 +200,14 @@ def visit_rename_table(
def visit_add_column(element: AddColumn, compiler: DDLCompiler, **kw) -> str:
return "%s %s" % (
alter_table(compiler, element.table_name, element.schema),
- add_column(compiler, element.column, **kw),
+ add_column(
+ compiler,
+ element.column,
+ if_not_exists=element.if_not_exists,
+ inline_references=element.inline_references,
+ inline_primary_key=element.inline_primary_key,
+ **kw,
+ ),
)
@@ -197,7 +215,9 @@ def visit_add_column(element: AddColumn, compiler: DDLCompiler, **kw) -> str:
def visit_drop_column(element: DropColumn, compiler: DDLCompiler, **kw) -> str:
return "%s %s" % (
alter_table(compiler, element.table_name, element.schema),
- drop_column(compiler, element.column.name, **kw),
+ drop_column(
+ compiler, element.column.name, if_exists=element.if_exists, **kw
+ ),
)
@@ -297,11 +317,15 @@ def format_column_name(
def format_server_default(
compiler: DDLCompiler,
- default: Optional[_ServerDefault],
+ default: Optional[_ServerDefaultType],
) -> str:
- return compiler.get_column_default_string(
+ # this can be updated to use compiler.render_default_string
+ # for SQLAlchemy 2.0 and above; not in 1.4
+ default_str = compiler.get_column_default_string(
Column("x", Integer, server_default=default)
)
+ assert default_str is not None
+ return default_str
def format_type(compiler: DDLCompiler, type_: TypeEngine) -> str:
@@ -316,16 +340,62 @@ def alter_table(
return "ALTER TABLE %s" % format_table_name(compiler, name, schema)
-def drop_column(compiler: DDLCompiler, name: str, **kw) -> str:
- return "DROP COLUMN %s" % format_column_name(compiler, name)
+def drop_column(
+ compiler: DDLCompiler, name: str, if_exists: Optional[bool] = None, **kw
+) -> str:
+ return "DROP COLUMN %s%s" % (
+ "IF EXISTS " if if_exists else "",
+ format_column_name(compiler, name),
+ )
def alter_column(compiler: DDLCompiler, name: str) -> str:
return "ALTER COLUMN %s" % format_column_name(compiler, name)
-def add_column(compiler: DDLCompiler, column: Column[Any], **kw) -> str:
- text = "ADD COLUMN %s" % compiler.get_column_specification(column, **kw)
+def add_column(
+ compiler: DDLCompiler,
+ column: Column[Any],
+ if_not_exists: Optional[bool] = None,
+ inline_references: Optional[bool] = None,
+ inline_primary_key: Optional[bool] = None,
+ **kw,
+) -> str:
+ text = "ADD COLUMN %s%s" % (
+ "IF NOT EXISTS " if if_not_exists else "",
+ compiler.get_column_specification(column, **kw),
+ )
+
+ if inline_primary_key and column.primary_key:
+ text += " PRIMARY KEY"
+
+ # Handle inline REFERENCES if requested
+ # Only render inline if there's exactly one foreign key AND the
+ # ForeignKeyConstraint is single-column, to avoid non-deterministic
+ # behavior with sets and to ensure proper syntax
+ if (
+ inline_references
+ and len(column.foreign_keys) == 1
+ and (fk := list(column.foreign_keys)[0])
+ and fk.constraint is not None
+ and len(fk.constraint.columns) == 1
+ ):
+ ref_col = fk.column
+ ref_table = ref_col.table
+
+ # Format with proper quoting
+ if ref_table.schema:
+ table_name = "%s.%s" % (
+ compiler.preparer.quote_schema(ref_table.schema),
+ compiler.preparer.quote(ref_table.name),
+ )
+ else:
+ table_name = compiler.preparer.quote(ref_table.name)
+
+ text += " REFERENCES %s (%s)" % (
+ table_name,
+ compiler.preparer.quote(ref_col.name),
+ )
const = " ".join(
compiler.process(constraint) for constraint in column.constraints
diff --git a/libs/alembic/ddl/impl.py b/libs/alembic/ddl/impl.py
index 2609a62dec..964cd1f30b 100644
--- a/libs/alembic/ddl/impl.py
+++ b/libs/alembic/ddl/impl.py
@@ -43,10 +43,13 @@
from sqlalchemy.engine import Connection
from sqlalchemy.engine import Dialect
from sqlalchemy.engine.cursor import CursorResult
+ from sqlalchemy.engine.interfaces import ReflectedForeignKeyConstraint
+ from sqlalchemy.engine.interfaces import ReflectedIndex
+ from sqlalchemy.engine.interfaces import ReflectedPrimaryKeyConstraint
+ from sqlalchemy.engine.interfaces import ReflectedUniqueConstraint
from sqlalchemy.engine.reflection import Inspector
from sqlalchemy.sql import ClauseElement
from sqlalchemy.sql import Executable
- from sqlalchemy.sql.elements import ColumnElement
from sqlalchemy.sql.elements import quoted_name
from sqlalchemy.sql.schema import Constraint
from sqlalchemy.sql.schema import ForeignKeyConstraint
@@ -55,11 +58,17 @@
from sqlalchemy.sql.selectable import TableClause
from sqlalchemy.sql.type_api import TypeEngine
- from .base import _ServerDefault
+ from .base import _ServerDefaultType
from ..autogenerate.api import AutogenContext
from ..operations.batch import ApplyBatchImpl
from ..operations.batch import BatchOperationsImpl
+ _ReflectedConstraint = (
+ ReflectedForeignKeyConstraint
+ | ReflectedPrimaryKeyConstraint
+ | ReflectedIndex
+ | ReflectedUniqueConstraint
+ )
log = logging.getLogger(__name__)
@@ -257,8 +266,11 @@ def alter_column(
self,
table_name: str,
column_name: str,
+ *,
nullable: Optional[bool] = None,
- server_default: Union[_ServerDefault, Literal[False]] = False,
+ server_default: Optional[
+ Union[_ServerDefaultType, Literal[False]]
+ ] = False,
name: Optional[str] = None,
type_: Optional[TypeEngine] = None,
schema: Optional[str] = None,
@@ -266,7 +278,9 @@ def alter_column(
comment: Optional[Union[str, Literal[False]]] = False,
existing_comment: Optional[str] = None,
existing_type: Optional[TypeEngine] = None,
- existing_server_default: Optional[_ServerDefault] = None,
+ existing_server_default: Optional[
+ Union[_ServerDefaultType, Literal[False]]
+ ] = None,
existing_nullable: Optional[bool] = None,
existing_autoincrement: Optional[bool] = None,
**kw: Any,
@@ -369,25 +383,47 @@ def add_column(
self,
table_name: str,
column: Column[Any],
+ *,
schema: Optional[Union[str, quoted_name]] = None,
+ if_not_exists: Optional[bool] = None,
+ inline_references: Optional[bool] = None,
+ inline_primary_key: Optional[bool] = None,
) -> None:
- self._exec(base.AddColumn(table_name, column, schema=schema))
+ self._exec(
+ base.AddColumn(
+ table_name,
+ column,
+ schema=schema,
+ if_not_exists=if_not_exists,
+ inline_references=inline_references,
+ inline_primary_key=inline_primary_key,
+ )
+ )
def drop_column(
self,
table_name: str,
column: Column[Any],
+ *,
schema: Optional[str] = None,
+ if_exists: Optional[bool] = None,
**kw,
) -> None:
- self._exec(base.DropColumn(table_name, column, schema=schema))
+ self._exec(
+ base.DropColumn(
+ table_name, column, schema=schema, if_exists=if_exists
+ )
+ )
- def add_constraint(self, const: Any) -> None:
+ def add_constraint(self, const: Any, **kw: Any) -> None:
if const._create_rule is None or const._create_rule(self):
- self._exec(schema.AddConstraint(const))
+ if sqla_compat.sqla_2_1:
+ # this should be the default already
+ kw.setdefault("isolate_from_table", True)
+ self._exec(schema.AddConstraint(const, **kw))
- def drop_constraint(self, const: Constraint) -> None:
- self._exec(schema.DropConstraint(const))
+ def drop_constraint(self, const: Constraint, **kw: Any) -> None:
+ self._exec(schema.DropConstraint(const, **kw))
def rename_table(
self,
@@ -440,7 +476,7 @@ def create_table_comment(self, table: Table) -> None:
def drop_table_comment(self, table: Table) -> None:
self._exec(schema.DropTableComment(table))
- def create_column_comment(self, column: ColumnElement[Any]) -> None:
+ def create_column_comment(self, column: Column[Any]) -> None:
self._exec(schema.SetColumnComment(column))
def drop_index(self, index: Index, **kw: Any) -> None:
@@ -459,7 +495,9 @@ def bulk_insert(
if self.as_sql:
for row in rows:
self._exec(
- sqla_compat._insert_inline(table).values(
+ table.insert()
+ .inline()
+ .values(
**{
k: (
sqla_compat._literal_bindparam(
@@ -477,14 +515,10 @@ def bulk_insert(
else:
if rows:
if multiinsert:
- self._exec(
- sqla_compat._insert_inline(table), multiparams=rows
- )
+ self._exec(table.insert().inline(), multiparams=rows)
else:
for row in rows:
- self._exec(
- sqla_compat._insert_inline(table).values(**row)
- )
+ self._exec(table.insert().inline().values(**row))
def _tokenize_column_type(self, column: Column) -> Params:
definition: str
@@ -693,7 +727,7 @@ def _compare_identity_default(self, metadata_identity, inspector_identity):
diff, ignored = _compare_identity_options(
metadata_identity,
inspector_identity,
- sqla_compat.Identity(),
+ schema.Identity(),
skip={"always"},
)
@@ -824,9 +858,9 @@ def _skip_functional_indexes(self, metadata_indexes, conn_indexes):
metadata_indexes.discard(idx)
def adjust_reflected_dialect_options(
- self, reflected_object: Dict[str, Any], kind: str
+ self, reflected_object: _ReflectedConstraint, kind: str
) -> Dict[str, Any]:
- return reflected_object.get("dialect_options", {})
+ return reflected_object.get("dialect_options", {}) # type: ignore[return-value] # noqa: E501
class Params(NamedTuple):
@@ -874,12 +908,13 @@ def check_dicts(
set(meta_d).union(insp_d),
)
if sqla_compat.identity_has_dialect_kwargs:
+ assert hasattr(default_io, "dialect_kwargs")
# use only the dialect kwargs in inspector_io since metadata_io
# can have options for many backends
check_dicts(
getattr(metadata_io, "dialect_kwargs", {}),
getattr(inspector_io, "dialect_kwargs", {}),
- default_io.dialect_kwargs, # type: ignore[union-attr]
+ default_io.dialect_kwargs,
getattr(inspector_io, "dialect_kwargs", {}),
)
diff --git a/libs/alembic/ddl/mssql.py b/libs/alembic/ddl/mssql.py
index baa43d5e73..91cd9e428d 100644
--- a/libs/alembic/ddl/mssql.py
+++ b/libs/alembic/ddl/mssql.py
@@ -20,6 +20,7 @@
from .base import AddColumn
from .base import alter_column
from .base import alter_table
+from .base import ColumnComment
from .base import ColumnDefault
from .base import ColumnName
from .base import ColumnNullable
@@ -45,7 +46,8 @@
from sqlalchemy.sql.selectable import TableClause
from sqlalchemy.sql.type_api import TypeEngine
- from .base import _ServerDefault
+ from .base import _ServerDefaultType
+ from .impl import _ReflectedConstraint
class MSSQLImpl(DefaultImpl):
@@ -83,19 +85,22 @@ def emit_commit(self) -> None:
if self.as_sql and self.batch_separator:
self.static_output(self.batch_separator)
- def alter_column( # type:ignore[override]
+ def alter_column(
self,
table_name: str,
column_name: str,
+ *,
nullable: Optional[bool] = None,
server_default: Optional[
- Union[_ServerDefault, Literal[False]]
+ Union[_ServerDefaultType, Literal[False]]
] = False,
name: Optional[str] = None,
type_: Optional[TypeEngine] = None,
schema: Optional[str] = None,
existing_type: Optional[TypeEngine] = None,
- existing_server_default: Optional[_ServerDefault] = None,
+ existing_server_default: Union[
+ _ServerDefaultType, Literal[False], None
+ ] = None,
existing_nullable: Optional[bool] = None,
**kw: Any,
) -> None:
@@ -137,6 +142,27 @@ def alter_column( # type:ignore[override]
kw["server_default"] = server_default
kw["existing_server_default"] = existing_server_default
+ # drop existing default constraints before changing type
+ # or default, see issue #1744
+ if (
+ server_default is not False
+ and used_default is False
+ and (
+ existing_server_default is not False or server_default is None
+ )
+ ):
+ self._exec(
+ _ExecDropConstraint(
+ table_name,
+ column_name,
+ "sys.default_constraints",
+ schema,
+ )
+ )
+
+ # TODO: see why these two alter_columns can't be called
+ # at once. joining them works but some of the mssql tests
+ # seem to expect something different
super().alter_column(
table_name,
column_name,
@@ -149,15 +175,6 @@ def alter_column( # type:ignore[override]
)
if server_default is not False and used_default is False:
- if existing_server_default is not False or server_default is None:
- self._exec(
- _ExecDropConstraint(
- table_name,
- column_name,
- "sys.default_constraints",
- schema,
- )
- )
if server_default is not None:
super().alter_column(
table_name,
@@ -202,6 +219,7 @@ def drop_column(
self,
table_name: str,
column: Column[Any],
+ *,
schema: Optional[str] = None,
**kw,
) -> None:
@@ -265,10 +283,10 @@ def _compare_identity_default(self, metadata_identity, inspector_identity):
return diff, ignored, is_alter
def adjust_reflected_dialect_options(
- self, reflected_object: Dict[str, Any], kind: str
+ self, reflected_object: _ReflectedConstraint, kind: str
) -> Dict[str, Any]:
options: Dict[str, Any]
- options = reflected_object.get("dialect_options", {}).copy()
+ options = reflected_object.get("dialect_options", {}).copy() # type: ignore[attr-defined] # noqa: E501
if not options.get("mssql_include"):
options.pop("mssql_include", None)
if not options.get("mssql_clustered"):
@@ -417,3 +435,89 @@ def visit_rename_table(
format_table_name(compiler, element.table_name, element.schema),
format_table_name(compiler, element.new_table_name, None),
)
+
+
+def _add_column_comment(
+ compiler: MSDDLCompiler,
+ schema: Optional[str],
+ tname: str,
+ cname: str,
+ comment: str,
+) -> str:
+ schema_name = schema if schema else compiler.dialect.default_schema_name
+ assert schema_name
+ return (
+ "exec sp_addextendedproperty 'MS_Description', {}, "
+ "'schema', {}, 'table', {}, 'column', {}".format(
+ compiler.sql_compiler.render_literal_value(
+ comment, sqltypes.NVARCHAR()
+ ),
+ compiler.preparer.quote_schema(schema_name),
+ compiler.preparer.quote(tname),
+ compiler.preparer.quote(cname),
+ )
+ )
+
+
+def _update_column_comment(
+ compiler: MSDDLCompiler,
+ schema: Optional[str],
+ tname: str,
+ cname: str,
+ comment: str,
+) -> str:
+ schema_name = schema if schema else compiler.dialect.default_schema_name
+ assert schema_name
+ return (
+ "exec sp_updateextendedproperty 'MS_Description', {}, "
+ "'schema', {}, 'table', {}, 'column', {}".format(
+ compiler.sql_compiler.render_literal_value(
+ comment, sqltypes.NVARCHAR()
+ ),
+ compiler.preparer.quote_schema(schema_name),
+ compiler.preparer.quote(tname),
+ compiler.preparer.quote(cname),
+ )
+ )
+
+
+def _drop_column_comment(
+ compiler: MSDDLCompiler, schema: Optional[str], tname: str, cname: str
+) -> str:
+ schema_name = schema if schema else compiler.dialect.default_schema_name
+ assert schema_name
+ return (
+ "exec sp_dropextendedproperty 'MS_Description', "
+ "'schema', {}, 'table', {}, 'column', {}".format(
+ compiler.preparer.quote_schema(schema_name),
+ compiler.preparer.quote(tname),
+ compiler.preparer.quote(cname),
+ )
+ )
+
+
+@compiles(ColumnComment, "mssql")
+def visit_column_comment(
+ element: ColumnComment, compiler: MSDDLCompiler, **kw: Any
+) -> str:
+ if element.comment is not None:
+ if element.existing_comment is not None:
+ return _update_column_comment(
+ compiler,
+ element.schema,
+ element.table_name,
+ element.column_name,
+ element.comment,
+ )
+ else:
+ return _add_column_comment(
+ compiler,
+ element.schema,
+ element.table_name,
+ element.column_name,
+ element.comment,
+ )
+ else:
+ return _drop_column_comment(
+ compiler, element.schema, element.table_name, element.column_name
+ )
diff --git a/libs/alembic/ddl/mysql.py b/libs/alembic/ddl/mysql.py
index 3482f672da..27f808b050 100644
--- a/libs/alembic/ddl/mysql.py
+++ b/libs/alembic/ddl/mysql.py
@@ -11,6 +11,9 @@
from sqlalchemy import schema
from sqlalchemy import types as sqltypes
+from sqlalchemy.sql import elements
+from sqlalchemy.sql import functions
+from sqlalchemy.sql import operators
from .base import alter_table
from .base import AlterColumn
@@ -23,7 +26,6 @@
from .impl import DefaultImpl
from .. import util
from ..util import sqla_compat
-from ..util.sqla_compat import _is_mariadb
from ..util.sqla_compat import _is_type_bound
from ..util.sqla_compat import compiles
@@ -32,10 +34,11 @@
from sqlalchemy.dialects.mysql.base import MySQLDDLCompiler
from sqlalchemy.sql.ddl import DropConstraint
+ from sqlalchemy.sql.elements import ClauseElement
from sqlalchemy.sql.schema import Constraint
from sqlalchemy.sql.type_api import TypeEngine
- from .base import _ServerDefault
+ from .base import _ServerDefaultType
class MySQLImpl(DefaultImpl):
@@ -48,17 +51,47 @@ class MySQLImpl(DefaultImpl):
)
type_arg_extract = [r"character set ([\w\-_]+)", r"collate ([\w\-_]+)"]
- def alter_column( # type:ignore[override]
+ def render_ddl_sql_expr(
+ self,
+ expr: ClauseElement,
+ is_server_default: bool = False,
+ is_index: bool = False,
+ **kw: Any,
+ ) -> str:
+ # apply Grouping to index expressions;
+ # see https://github.com/sqlalchemy/sqlalchemy/blob/
+ # 36da2eaf3e23269f2cf28420ae73674beafd0661/
+ # lib/sqlalchemy/dialects/mysql/base.py#L2191
+ if is_index and (
+ isinstance(expr, elements.BinaryExpression)
+ or (
+ isinstance(expr, elements.UnaryExpression)
+ and expr.modifier not in (operators.desc_op, operators.asc_op)
+ )
+ or isinstance(expr, functions.FunctionElement)
+ ):
+ expr = elements.Grouping(expr)
+
+ return super().render_ddl_sql_expr(
+ expr, is_server_default=is_server_default, is_index=is_index, **kw
+ )
+
+ def alter_column(
self,
table_name: str,
column_name: str,
+ *,
nullable: Optional[bool] = None,
- server_default: Union[_ServerDefault, Literal[False]] = False,
+ server_default: Optional[
+ Union[_ServerDefaultType, Literal[False]]
+ ] = False,
name: Optional[str] = None,
type_: Optional[TypeEngine] = None,
schema: Optional[str] = None,
existing_type: Optional[TypeEngine] = None,
- existing_server_default: Optional[_ServerDefault] = None,
+ existing_server_default: Optional[
+ Union[_ServerDefaultType, Literal[False]]
+ ] = None,
existing_nullable: Optional[bool] = None,
autoincrement: Optional[bool] = None,
existing_autoincrement: Optional[bool] = None,
@@ -166,6 +199,7 @@ def alter_column( # type:ignore[override]
def drop_constraint(
self,
const: Constraint,
+ **kw: Any,
) -> None:
if isinstance(const, schema.CheckConstraint) and _is_type_bound(const):
return
@@ -175,7 +209,7 @@ def drop_constraint(
def _is_mysql_allowed_functional_default(
self,
type_: Optional[TypeEngine],
- server_default: Union[_ServerDefault, Literal[False]],
+ server_default: Optional[Union[_ServerDefaultType, Literal[False]]],
) -> bool:
return (
type_ is not None
@@ -326,7 +360,7 @@ def __init__(
self,
name: str,
column_name: str,
- default: _ServerDefault,
+ default: Optional[_ServerDefaultType],
schema: Optional[str] = None,
) -> None:
super(AlterColumn, self).__init__(name, schema=schema)
@@ -343,7 +377,7 @@ def __init__(
newname: Optional[str] = None,
type_: Optional[TypeEngine] = None,
nullable: Optional[bool] = None,
- default: Optional[Union[_ServerDefault, Literal[False]]] = False,
+ default: Optional[Union[_ServerDefaultType, Literal[False]]] = False,
autoincrement: Optional[bool] = None,
comment: Optional[Union[str, Literal[False]]] = False,
) -> None:
@@ -432,7 +466,7 @@ def _mysql_change_column(
def _mysql_colspec(
compiler: MySQLDDLCompiler,
nullable: Optional[bool],
- server_default: Optional[Union[_ServerDefault, Literal[False]]],
+ server_default: Optional[Union[_ServerDefaultType, Literal[False]]],
type_: TypeEngine,
autoincrement: Optional[bool],
comment: Optional[Union[str, Literal[False]]],
@@ -475,7 +509,7 @@ def _mysql_drop_constraint(
# note that SQLAlchemy as of 1.2 does not yet support
# DROP CONSTRAINT for MySQL/MariaDB, so we implement fully
# here.
- if _is_mariadb(compiler.dialect):
+ if compiler.dialect.is_mariadb:
return "ALTER TABLE %s DROP CONSTRAINT %s" % (
compiler.preparer.format_table(constraint.table),
compiler.preparer.format_constraint(constraint),
diff --git a/libs/alembic/ddl/postgresql.py b/libs/alembic/ddl/postgresql.py
index de64a4e05b..cc03f45346 100644
--- a/libs/alembic/ddl/postgresql.py
+++ b/libs/alembic/ddl/postgresql.py
@@ -16,8 +16,11 @@
from typing import Union
from sqlalchemy import Column
+from sqlalchemy import Float
+from sqlalchemy import Identity
from sqlalchemy import literal_column
from sqlalchemy import Numeric
+from sqlalchemy import select
from sqlalchemy import text
from sqlalchemy import types as sqltypes
from sqlalchemy.dialects.postgresql import BIGINT
@@ -49,6 +52,7 @@
from ..util import sqla_compat
from ..util.sqla_compat import compiles
+
if TYPE_CHECKING:
from typing import Literal
@@ -66,12 +70,12 @@
from sqlalchemy.sql.schema import Table
from sqlalchemy.sql.type_api import TypeEngine
- from .base import _ServerDefault
+ from .base import _ServerDefaultType
+ from .impl import _ReflectedConstraint
from ..autogenerate.api import AutogenContext
from ..autogenerate.render import _f_name
from ..runtime.migration import MigrationContext
-
log = logging.getLogger(__name__)
@@ -109,6 +113,7 @@ def compare_server_default(
rendered_metadata_default,
rendered_inspector_default,
):
+
# don't do defaults for SERIAL columns
if (
metadata_column.primary_key
@@ -118,6 +123,11 @@ def compare_server_default(
conn_col_default = rendered_inspector_default
+ if conn_col_default and re.match(
+ r"nextval\('(.+?)'::regclass\)", conn_col_default
+ ):
+ conn_col_default = conn_col_default.replace("::regclass", "")
+
defaults_equal = conn_col_default == rendered_metadata_default
if defaults_equal:
return False
@@ -132,33 +142,38 @@ def compare_server_default(
metadata_default = metadata_column.server_default.arg
if isinstance(metadata_default, str):
- if not isinstance(inspector_column.type, Numeric):
+ if not isinstance(inspector_column.type, (Numeric, Float)):
metadata_default = re.sub(r"^'|'$", "", metadata_default)
metadata_default = f"'{metadata_default}'"
metadata_default = literal_column(metadata_default)
# run a real compare against the server
+ # TODO: this seems quite a bad idea for a default that's a SQL
+ # function! SQL functions are not deterministic!
conn = self.connection
assert conn is not None
return not conn.scalar(
- sqla_compat._select(
- literal_column(conn_col_default) == metadata_default
- )
+ select(literal_column(conn_col_default) == metadata_default)
)
- def alter_column( # type:ignore[override]
+ def alter_column(
self,
table_name: str,
column_name: str,
+ *,
nullable: Optional[bool] = None,
- server_default: Union[_ServerDefault, Literal[False]] = False,
+ server_default: Optional[
+ Union[_ServerDefaultType, Literal[False]]
+ ] = False,
name: Optional[str] = None,
type_: Optional[TypeEngine] = None,
schema: Optional[str] = None,
autoincrement: Optional[bool] = None,
existing_type: Optional[TypeEngine] = None,
- existing_server_default: Optional[_ServerDefault] = None,
+ existing_server_default: Optional[
+ Union[_ServerDefaultType, Literal[False]]
+ ] = None,
existing_nullable: Optional[bool] = None,
existing_autoincrement: Optional[bool] = None,
**kw: Any,
@@ -314,7 +329,7 @@ def _dialect_options(
self, item: Union[Index, UniqueConstraint]
) -> Tuple[Any, ...]:
# only the positive case is returned by sqlalchemy reflection so
- # None and False are threated the same
+ # None and False are treated the same
if item.dialect_kwargs.get("postgresql_nulls_not_distinct"):
return ("nulls_not_distinct",)
return ()
@@ -408,10 +423,10 @@ def compare_unique_constraint(
return ComparisonResult.Equal()
def adjust_reflected_dialect_options(
- self, reflected_options: Dict[str, Any], kind: str
+ self, reflected_object: _ReflectedConstraint, kind: str
) -> Dict[str, Any]:
options: Dict[str, Any]
- options = reflected_options.get("dialect_options", {}).copy()
+ options = reflected_object.get("dialect_options", {}).copy() # type: ignore[attr-defined] # noqa: E501
if not options.get("postgresql_include"):
options.pop("postgresql_include", None)
return options
@@ -585,7 +600,7 @@ def visit_identity_column(
)
else:
text += "SET %s " % compiler.get_identity_options(
- sqla_compat.Identity(**{attr: getattr(identity, attr)})
+ Identity(**{attr: getattr(identity, attr)})
)
return text
@@ -845,5 +860,5 @@ def _render_potential_column(
return render._render_potential_expr(
value,
autogen_context,
- wrap_in_text=isinstance(value, (TextClause, FunctionElement)),
+ wrap_in_element=isinstance(value, (TextClause, FunctionElement)),
)
diff --git a/libs/alembic/ddl/sqlite.py b/libs/alembic/ddl/sqlite.py
index 762e8ca198..c260d53faa 100644
--- a/libs/alembic/ddl/sqlite.py
+++ b/libs/alembic/ddl/sqlite.py
@@ -11,11 +11,14 @@
from typing import Union
from sqlalchemy import cast
+from sqlalchemy import Computed
from sqlalchemy import JSON
from sqlalchemy import schema
from sqlalchemy import sql
from .base import alter_table
+from .base import ColumnName
+from .base import format_column_name
from .base import format_table_name
from .base import RenameTable
from .impl import DefaultImpl
@@ -62,7 +65,7 @@ def requires_recreate_in_batch(
) and isinstance(col.server_default.arg, sql.ClauseElement):
return True
elif (
- isinstance(col.server_default, util.sqla_compat.Computed)
+ isinstance(col.server_default, Computed)
and col.server_default.persisted
):
return True
@@ -71,7 +74,7 @@ def requires_recreate_in_batch(
else:
return False
- def add_constraint(self, const: Constraint):
+ def add_constraint(self, const: Constraint, **kw: Any):
# attempt to distinguish between an
# auto-gen constraint and an explicit one
if const._create_rule is None:
@@ -88,7 +91,7 @@ def add_constraint(self, const: Constraint):
"SQLite migrations using a copy-and-move strategy."
)
- def drop_constraint(self, const: Constraint):
+ def drop_constraint(self, const: Constraint, **kw: Any):
if const._create_rule is None:
raise NotImplementedError(
"No support for ALTER of constraints in SQLite dialect. "
@@ -207,6 +210,15 @@ def visit_rename_table(
)
+@compiles(ColumnName, "sqlite")
+def visit_column_name(element: ColumnName, compiler: DDLCompiler, **kw) -> str:
+ return "%s RENAME COLUMN %s TO %s" % (
+ alter_table(compiler, element.table_name, element.schema),
+ format_column_name(compiler, element.column_name),
+ format_column_name(compiler, element.newname),
+ )
+
+
# @compiles(AddColumn, 'sqlite')
# def visit_add_column(element, compiler, **kw):
# return "%s %s" % (
diff --git a/libs/alembic/op.pyi b/libs/alembic/op.pyi
index 920444696e..7fadaf5fe6 100644
--- a/libs/alembic/op.pyi
+++ b/libs/alembic/op.pyi
@@ -27,15 +27,13 @@ if TYPE_CHECKING:
from sqlalchemy.sql.elements import conv
from sqlalchemy.sql.elements import TextClause
from sqlalchemy.sql.expression import TableClause
- from sqlalchemy.sql.functions import Function
from sqlalchemy.sql.schema import Column
- from sqlalchemy.sql.schema import Computed
- from sqlalchemy.sql.schema import Identity
from sqlalchemy.sql.schema import SchemaItem
from sqlalchemy.sql.schema import Table
from sqlalchemy.sql.type_api import TypeEngine
from sqlalchemy.util import immutabledict
+ from .ddl.base import _ServerDefaultType
from .operations.base import BatchOperations
from .operations.ops import AddColumnOp
from .operations.ops import AddConstraintOp
@@ -61,7 +59,13 @@ _C = TypeVar("_C", bound=Callable[..., Any])
### end imports ###
def add_column(
- table_name: str, column: Column[Any], *, schema: Optional[str] = None
+ table_name: str,
+ column: Column[Any],
+ *,
+ schema: Optional[str] = None,
+ if_not_exists: Optional[bool] = None,
+ inline_references: Optional[bool] = None,
+ inline_primary_key: Optional[bool] = None,
) -> None:
"""Issue an "add column" instruction using the current
migration context.
@@ -76,36 +80,64 @@ def add_column(
The :meth:`.Operations.add_column` method typically corresponds
to the SQL command "ALTER TABLE... ADD COLUMN". Within the scope
of this command, the column's name, datatype, nullability,
- and optional server-generated defaults may be indicated.
+ and optional server-generated defaults may be indicated. Options
+ also exist for control of single-column primary key and foreign key
+ constraints to be generated.
.. note::
- With the exception of NOT NULL constraints or single-column FOREIGN
- KEY constraints, other kinds of constraints such as PRIMARY KEY,
- UNIQUE or CHECK constraints **cannot** be generated using this
- method; for these constraints, refer to operations such as
- :meth:`.Operations.create_primary_key` and
- :meth:`.Operations.create_check_constraint`. In particular, the
- following :class:`~sqlalchemy.schema.Column` parameters are
- **ignored**:
-
- * :paramref:`~sqlalchemy.schema.Column.primary_key` - SQL databases
- typically do not support an ALTER operation that can add
- individual columns one at a time to an existing primary key
- constraint, therefore it's less ambiguous to use the
- :meth:`.Operations.create_primary_key` method, which assumes no
- existing primary key constraint is present.
+ Not all contraint types may be indicated with this directive.
+ NOT NULL, FOREIGN KEY, and CHECK are honored, PRIMARY KEY
+ is conditionally honored, UNIQUE
+ is currently not.
+
+ As of 1.18.2, the following :class:`~sqlalchemy.schema.Column`
+ parameters are **ignored**:
+
* :paramref:`~sqlalchemy.schema.Column.unique` - use the
:meth:`.Operations.create_unique_constraint` method
* :paramref:`~sqlalchemy.schema.Column.index` - use the
:meth:`.Operations.create_index` method
+ **PRIMARY KEY support**
+
+ The provided :class:`~sqlalchemy.schema.Column` object may include a
+ ``primary_key=True`` directive, indicating the column intends to be
+ part of a primary key constraint. However by default, the inline
+ "PRIMARY KEY" directive is not emitted, and it's assumed that a
+ separate :meth:`.Operations.create_primary_key` directive will be used
+ to create this constraint, which may potentially include other columns
+ as well as have an explicit name. To instead render an inline
+ "PRIMARY KEY" directive, the
+ :paramref:`.AddColumnOp.inline_primary_key` parameter may be indicated
+ at the same time as the ``primary_key`` parameter (both are needed)::
+
+ from alembic import op
+ from sqlalchemy import Column, INTEGER
+
+ op.add_column(
+ "organization",
+ Column("id", INTEGER, primary_key=True),
+ inline_primary_key=True
+ )
+
+ The ``primary_key=True`` parameter on
+ :class:`~sqlalchemy.schema.Column` also indicates behaviors such as
+ using the ``SERIAL`` datatype with the PostgreSQL database, which is
+ why two separate, independent parameters are provided to support all
+ combinations.
+
+ .. versionadded:: 1.18.4 Added
+ :paramref:`.AddColumnOp.inline_primary_key`
+ to control use of the ``PRIMARY KEY`` inline directive.
+
+ **FOREIGN KEY support**
The provided :class:`~sqlalchemy.schema.Column` object may include a
:class:`~sqlalchemy.schema.ForeignKey` constraint directive,
- referencing a remote table name. For this specific type of constraint,
- Alembic will automatically emit a second ALTER statement in order to
- add the single-column FOREIGN KEY constraint separately::
+ referencing a remote table name. By default, Alembic will automatically
+ emit a second ALTER statement in order to add the single-column FOREIGN
+ KEY constraint separately::
from alembic import op
from sqlalchemy import Column, INTEGER, ForeignKey
@@ -115,6 +147,22 @@ def add_column(
Column("account_id", INTEGER, ForeignKey("accounts.id")),
)
+ To render the FOREIGN KEY constraint inline within the ADD COLUMN
+ directive, use the ``inline_references`` parameter. This can improve
+ performance on large tables since the constraint is marked as valid
+ immediately for nullable columns::
+
+ from alembic import op
+ from sqlalchemy import Column, INTEGER, ForeignKey
+
+ op.add_column(
+ "organization",
+ Column("account_id", INTEGER, ForeignKey("accounts.id")),
+ inline_references=True,
+ )
+
+ **Indicating server side defaults**
+
The column argument passed to :meth:`.Operations.add_column` is a
:class:`~sqlalchemy.schema.Column` construct, used in the same way it's
used in SQLAlchemy. In particular, values or functions to be indicated
@@ -138,6 +186,27 @@ def add_column(
quoting of the schema outside of the default behavior, use
the SQLAlchemy construct
:class:`~sqlalchemy.sql.elements.quoted_name`.
+ :param if_not_exists: If True, adds ``IF NOT EXISTS`` operator
+ when creating the new column for compatible dialects
+
+ .. versionadded:: 1.16.0
+
+ :param inline_references: If True, renders ``FOREIGN KEY`` constraints
+ inline within the ``ADD COLUMN`` directive using ``REFERENCES``
+ syntax, rather than as a separate ``ALTER TABLE ADD CONSTRAINT``
+ statement. This is supported by PostgreSQL, Oracle, MySQL 5.7+, and
+ MariaDB 10.5+.
+
+ .. versionadded:: 1.18.2
+
+ :param inline_primary_key: If True, renders the ``PRIMARY KEY`` phrase
+ inline within the ``ADD COLUMN`` directive. When not present or
+ False, ``PRIMARY KEY`` is not emitted; it is assumed that the
+ migration script will include an additional
+ :meth:`.Operations.create_primary_key` directive to create a full
+ primary key constraint.
+
+ .. versionadded:: 1.18.4
"""
@@ -147,12 +216,12 @@ def alter_column(
*,
nullable: Optional[bool] = None,
comment: Union[str, Literal[False], None] = False,
- server_default: Any = False,
+ server_default: Union[_ServerDefaultType, None, Literal[False]] = False,
new_column_name: Optional[str] = None,
type_: Union[TypeEngine[Any], Type[TypeEngine[Any]], None] = None,
existing_type: Union[TypeEngine[Any], Type[TypeEngine[Any]], None] = None,
existing_server_default: Union[
- str, bool, Identity, Computed, None
+ _ServerDefaultType, None, Literal[False]
] = False,
existing_nullable: Optional[bool] = None,
existing_comment: Optional[str] = None,
@@ -247,7 +316,7 @@ def batch_alter_table(
table_name: str,
schema: Optional[str] = None,
recreate: Literal["auto", "always", "never"] = "auto",
- partial_reordering: Optional[Tuple[Any, ...]] = None,
+ partial_reordering: list[tuple[str, ...]] | None = None,
copy_from: Optional[Table] = None,
table_args: Tuple[Any, ...] = (),
table_kwargs: Mapping[str, Any] = immutabledict({}),
@@ -650,7 +719,7 @@ def create_foreign_key(
def create_index(
index_name: Optional[str],
table_name: str,
- columns: Sequence[Union[str, TextClause, Function[Any]]],
+ columns: Sequence[Union[str, TextClause, ColumnElement[Any]]],
*,
schema: Optional[str] = None,
unique: bool = False,
@@ -926,6 +995,11 @@ def drop_column(
quoting of the schema outside of the default behavior, use
the SQLAlchemy construct
:class:`~sqlalchemy.sql.elements.quoted_name`.
+ :param if_exists: If True, adds IF EXISTS operator when
+ dropping the new column for compatible dialects
+
+ .. versionadded:: 1.16.0
+
:param mssql_drop_check: Optional boolean. When ``True``, on
Microsoft SQL Server only, first
drop the CHECK constraint on the column using a
@@ -947,7 +1021,6 @@ def drop_column(
then exec's a separate DROP CONSTRAINT for that default. Only
works if the column has exactly one FK constraint which refers to
it, at the moment.
-
"""
def drop_constraint(
@@ -956,6 +1029,7 @@ def drop_constraint(
type_: Optional[str] = None,
*,
schema: Optional[str] = None,
+ if_exists: Optional[bool] = None,
) -> None:
r"""Drop a constraint of the given name, typically via DROP CONSTRAINT.
@@ -967,6 +1041,10 @@ def drop_constraint(
quoting of the schema outside of the default behavior, use
the SQLAlchemy construct
:class:`~sqlalchemy.sql.elements.quoted_name`.
+ :param if_exists: If True, adds IF EXISTS operator when
+ dropping the constraint
+
+ .. versionadded:: 1.16.0
"""
@@ -1166,7 +1244,7 @@ def f(name: str) -> conv:
names will be converted along conventions. If the ``target_metadata``
contains the naming convention
``{"ck": "ck_bool_%(table_name)s_%(constraint_name)s"}``, then the
- output of the following:
+ output of the following::
op.add_column("t", "x", Boolean(name="x"))
@@ -1196,14 +1274,27 @@ def get_context() -> MigrationContext:
"""
-def implementation_for(op_cls: Any) -> Callable[[_C], _C]:
+def implementation_for(
+ op_cls: Any, replace: bool = False
+) -> Callable[[_C], _C]:
"""Register an implementation for a given :class:`.MigrateOperation`.
+ :param replace: when True, allows replacement of an already
+ registered implementation for the given operation class. This
+ enables customization of built-in operations such as
+ :class:`.CreateTableOp` by providing an alternate implementation
+ that can augment, modify, or conditionally invoke the default
+ behavior.
+
+ .. versionadded:: 1.17.2
+
This is part of the operation extensibility API.
.. seealso::
- :ref:`operation_plugins` - example of use
+ :ref:`operation_plugins`
+
+ :ref:`operations_extending_builtin`
"""
@@ -1270,7 +1361,7 @@ def invoke(
BulkInsertOp,
DropTableOp,
ExecuteSQLOp,
- ]
+ ],
) -> None: ...
@overload
def invoke(operation: MigrateOperation) -> Any:
diff --git a/libs/alembic/operations/base.py b/libs/alembic/operations/base.py
index 9b52fa6f29..b9e6107fba 100644
--- a/libs/alembic/operations/base.py
+++ b/libs/alembic/operations/base.py
@@ -27,13 +27,13 @@
from . import batch
from . import schemaobj
from .. import util
+from ..ddl.base import _ServerDefaultType
from ..util import sqla_compat
from ..util.compat import formatannotation_fwdref
from ..util.compat import inspect_formatargspec
from ..util.compat import inspect_getfullargspec
from ..util.sqla_compat import _literal_bindparam
-
if TYPE_CHECKING:
from typing import Literal
@@ -43,10 +43,7 @@
from sqlalchemy.sql.expression import ColumnElement
from sqlalchemy.sql.expression import TableClause
from sqlalchemy.sql.expression import TextClause
- from sqlalchemy.sql.functions import Function
from sqlalchemy.sql.schema import Column
- from sqlalchemy.sql.schema import Computed
- from sqlalchemy.sql.schema import Identity
from sqlalchemy.sql.schema import SchemaItem
from sqlalchemy.types import TypeEngine
@@ -203,19 +200,32 @@ def %(name)s%(args)s:
return register
@classmethod
- def implementation_for(cls, op_cls: Any) -> Callable[[_C], _C]:
+ def implementation_for(
+ cls, op_cls: Any, replace: bool = False
+ ) -> Callable[[_C], _C]:
"""Register an implementation for a given :class:`.MigrateOperation`.
+ :param replace: when True, allows replacement of an already
+ registered implementation for the given operation class. This
+ enables customization of built-in operations such as
+ :class:`.CreateTableOp` by providing an alternate implementation
+ that can augment, modify, or conditionally invoke the default
+ behavior.
+
+ .. versionadded:: 1.17.2
+
This is part of the operation extensibility API.
.. seealso::
- :ref:`operation_plugins` - example of use
+ :ref:`operation_plugins`
+
+ :ref:`operations_extending_builtin`
"""
def decorate(fn: _C) -> _C:
- cls._to_impl.dispatch_for(op_cls)(fn)
+ cls._to_impl.dispatch_for(op_cls, replace=replace)(fn)
return fn
return decorate
@@ -236,7 +246,7 @@ def batch_alter_table(
table_name: str,
schema: Optional[str] = None,
recreate: Literal["auto", "always", "never"] = "auto",
- partial_reordering: Optional[Tuple[Any, ...]] = None,
+ partial_reordering: list[tuple[str, ...]] | None = None,
copy_from: Optional[Table] = None,
table_args: Tuple[Any, ...] = (),
table_kwargs: Mapping[str, Any] = util.immutabledict(),
@@ -465,7 +475,7 @@ def f(self, name: str) -> conv:
names will be converted along conventions. If the ``target_metadata``
contains the naming convention
``{"ck": "ck_bool_%(table_name)s_%(constraint_name)s"}``, then the
- output of the following:
+ output of the following::
op.add_column("t", "x", Boolean(name="x"))
@@ -619,6 +629,9 @@ def add_column(
column: Column[Any],
*,
schema: Optional[str] = None,
+ if_not_exists: Optional[bool] = None,
+ inline_references: Optional[bool] = None,
+ inline_primary_key: Optional[bool] = None,
) -> None:
"""Issue an "add column" instruction using the current
migration context.
@@ -633,36 +646,77 @@ def add_column(
The :meth:`.Operations.add_column` method typically corresponds
to the SQL command "ALTER TABLE... ADD COLUMN". Within the scope
of this command, the column's name, datatype, nullability,
- and optional server-generated defaults may be indicated.
+ and optional server-generated defaults may be indicated. Options
+ also exist for control of single-column primary key and foreign key
+ constraints to be generated.
.. note::
- With the exception of NOT NULL constraints or single-column FOREIGN
- KEY constraints, other kinds of constraints such as PRIMARY KEY,
- UNIQUE or CHECK constraints **cannot** be generated using this
- method; for these constraints, refer to operations such as
- :meth:`.Operations.create_primary_key` and
- :meth:`.Operations.create_check_constraint`. In particular, the
- following :class:`~sqlalchemy.schema.Column` parameters are
- **ignored**:
-
- * :paramref:`~sqlalchemy.schema.Column.primary_key` - SQL databases
- typically do not support an ALTER operation that can add
- individual columns one at a time to an existing primary key
- constraint, therefore it's less ambiguous to use the
- :meth:`.Operations.create_primary_key` method, which assumes no
- existing primary key constraint is present.
+ Not all contraint types may be indicated with this directive.
+ NOT NULL, FOREIGN KEY, and CHECK are honored, PRIMARY KEY
+ is conditionally honored, UNIQUE
+ is currently not.
+
+ As of 1.18.2, the following :class:`~sqlalchemy.schema.Column`
+ parameters are **ignored**:
+
* :paramref:`~sqlalchemy.schema.Column.unique` - use the
:meth:`.Operations.create_unique_constraint` method
* :paramref:`~sqlalchemy.schema.Column.index` - use the
:meth:`.Operations.create_index` method
+ **PRIMARY KEY support**
+
+ The provided :class:`~sqlalchemy.schema.Column` object may include a
+ ``primary_key=True`` directive, indicating the column intends to be
+ part of a primary key constraint. However by default, the inline
+ "PRIMARY KEY" directive is not emitted, and it's assumed that a
+ separate :meth:`.Operations.create_primary_key` directive will be used
+ to create this constraint, which may potentially include other columns
+ as well as have an explicit name. To instead render an inline
+ "PRIMARY KEY" directive, the
+ :paramref:`.AddColumnOp.inline_primary_key` parameter may be indicated
+ at the same time as the ``primary_key`` parameter (both are needed)::
+
+ from alembic import op
+ from sqlalchemy import Column, INTEGER
+
+ op.add_column(
+ "organization",
+ Column("id", INTEGER, primary_key=True),
+ inline_primary_key=True
+ )
+
+ The ``primary_key=True`` parameter on
+ :class:`~sqlalchemy.schema.Column` also indicates behaviors such as
+ using the ``SERIAL`` datatype with the PostgreSQL database, which is
+ why two separate, independent parameters are provided to support all
+ combinations.
+
+ .. versionadded:: 1.18.4 Added
+ :paramref:`.AddColumnOp.inline_primary_key`
+ to control use of the ``PRIMARY KEY`` inline directive.
+
+ **FOREIGN KEY support**
The provided :class:`~sqlalchemy.schema.Column` object may include a
:class:`~sqlalchemy.schema.ForeignKey` constraint directive,
- referencing a remote table name. For this specific type of constraint,
- Alembic will automatically emit a second ALTER statement in order to
- add the single-column FOREIGN KEY constraint separately::
+ referencing a remote table name. By default, Alembic will automatically
+ emit a second ALTER statement in order to add the single-column FOREIGN
+ KEY constraint separately::
+
+ from alembic import op
+ from sqlalchemy import Column, INTEGER, ForeignKey
+
+ op.add_column(
+ "organization",
+ Column("account_id", INTEGER, ForeignKey("accounts.id")),
+ )
+
+ To render the FOREIGN KEY constraint inline within the ADD COLUMN
+ directive, use the ``inline_references`` parameter. This can improve
+ performance on large tables since the constraint is marked as valid
+ immediately for nullable columns::
from alembic import op
from sqlalchemy import Column, INTEGER, ForeignKey
@@ -670,8 +724,11 @@ def add_column(
op.add_column(
"organization",
Column("account_id", INTEGER, ForeignKey("accounts.id")),
+ inline_references=True,
)
+ **Indicating server side defaults**
+
The column argument passed to :meth:`.Operations.add_column` is a
:class:`~sqlalchemy.schema.Column` construct, used in the same way it's
used in SQLAlchemy. In particular, values or functions to be indicated
@@ -695,6 +752,27 @@ def add_column(
quoting of the schema outside of the default behavior, use
the SQLAlchemy construct
:class:`~sqlalchemy.sql.elements.quoted_name`.
+ :param if_not_exists: If True, adds ``IF NOT EXISTS`` operator
+ when creating the new column for compatible dialects
+
+ .. versionadded:: 1.16.0
+
+ :param inline_references: If True, renders ``FOREIGN KEY`` constraints
+ inline within the ``ADD COLUMN`` directive using ``REFERENCES``
+ syntax, rather than as a separate ``ALTER TABLE ADD CONSTRAINT``
+ statement. This is supported by PostgreSQL, Oracle, MySQL 5.7+, and
+ MariaDB 10.5+.
+
+ .. versionadded:: 1.18.2
+
+ :param inline_primary_key: If True, renders the ``PRIMARY KEY`` phrase
+ inline within the ``ADD COLUMN`` directive. When not present or
+ False, ``PRIMARY KEY`` is not emitted; it is assumed that the
+ migration script will include an additional
+ :meth:`.Operations.create_primary_key` directive to create a full
+ primary key constraint.
+
+ .. versionadded:: 1.18.4
""" # noqa: E501
...
@@ -706,14 +784,16 @@ def alter_column(
*,
nullable: Optional[bool] = None,
comment: Union[str, Literal[False], None] = False,
- server_default: Any = False,
+ server_default: Union[
+ _ServerDefaultType, None, Literal[False]
+ ] = False,
new_column_name: Optional[str] = None,
type_: Union[TypeEngine[Any], Type[TypeEngine[Any]], None] = None,
existing_type: Union[
TypeEngine[Any], Type[TypeEngine[Any]], None
] = None,
existing_server_default: Union[
- str, bool, Identity, Computed, None
+ _ServerDefaultType, None, Literal[False]
] = False,
existing_nullable: Optional[bool] = None,
existing_comment: Optional[str] = None,
@@ -1074,7 +1154,7 @@ def create_index(
self,
index_name: Optional[str],
table_name: str,
- columns: Sequence[Union[str, TextClause, Function[Any]]],
+ columns: Sequence[Union[str, TextClause, ColumnElement[Any]]],
*,
schema: Optional[str] = None,
unique: bool = False,
@@ -1360,6 +1440,11 @@ def drop_column(
quoting of the schema outside of the default behavior, use
the SQLAlchemy construct
:class:`~sqlalchemy.sql.elements.quoted_name`.
+ :param if_exists: If True, adds IF EXISTS operator when
+ dropping the new column for compatible dialects
+
+ .. versionadded:: 1.16.0
+
:param mssql_drop_check: Optional boolean. When ``True``, on
Microsoft SQL Server only, first
drop the CHECK constraint on the column using a
@@ -1381,7 +1466,6 @@ def drop_column(
then exec's a separate DROP CONSTRAINT for that default. Only
works if the column has exactly one FK constraint which refers to
it, at the moment.
-
""" # noqa: E501
...
@@ -1392,6 +1476,7 @@ def drop_constraint(
type_: Optional[str] = None,
*,
schema: Optional[str] = None,
+ if_exists: Optional[bool] = None,
) -> None:
r"""Drop a constraint of the given name, typically via DROP CONSTRAINT.
@@ -1403,6 +1488,10 @@ def drop_constraint(
quoting of the schema outside of the default behavior, use
the SQLAlchemy construct
:class:`~sqlalchemy.sql.elements.quoted_name`.
+ :param if_exists: If True, adds IF EXISTS operator when
+ dropping the constraint
+
+ .. versionadded:: 1.16.0
""" # noqa: E501
...
@@ -1645,6 +1734,9 @@ def add_column(
*,
insert_before: Optional[str] = None,
insert_after: Optional[str] = None,
+ if_not_exists: Optional[bool] = None,
+ inline_references: Optional[bool] = None,
+ inline_primary_key: Optional[bool] = None,
) -> None:
"""Issue an "add column" instruction using the current
batch migration context.
@@ -1662,14 +1754,16 @@ def alter_column(
*,
nullable: Optional[bool] = None,
comment: Union[str, Literal[False], None] = False,
- server_default: Any = False,
+ server_default: Union[
+ _ServerDefaultType, None, Literal[False]
+ ] = False,
new_column_name: Optional[str] = None,
type_: Union[TypeEngine[Any], Type[TypeEngine[Any]], None] = None,
existing_type: Union[
TypeEngine[Any], Type[TypeEngine[Any]], None
] = None,
existing_server_default: Union[
- str, bool, Identity, Computed, None
+ _ServerDefaultType, None, Literal[False]
] = False,
existing_nullable: Optional[bool] = None,
existing_comment: Optional[str] = None,
diff --git a/libs/alembic/operations/batch.py b/libs/alembic/operations/batch.py
index fd7ab99030..9b48be5986 100644
--- a/libs/alembic/operations/batch.py
+++ b/libs/alembic/operations/batch.py
@@ -18,6 +18,7 @@
from sqlalchemy import MetaData
from sqlalchemy import PrimaryKeyConstraint
from sqlalchemy import schema as sql_schema
+from sqlalchemy import select
from sqlalchemy import Table
from sqlalchemy import types as sqltypes
from sqlalchemy.sql.schema import SchemaEventTarget
@@ -31,11 +32,9 @@
from ..util.sqla_compat import _ensure_scope_for_ddl
from ..util.sqla_compat import _fk_is_self_referential
from ..util.sqla_compat import _idx_table_bound_expressions
-from ..util.sqla_compat import _insert_inline
from ..util.sqla_compat import _is_type_bound
from ..util.sqla_compat import _remove_column_from_collection
from ..util.sqla_compat import _resolve_for_variant
-from ..util.sqla_compat import _select
from ..util.sqla_compat import constraint_name_defined
from ..util.sqla_compat import constraint_name_string
@@ -45,10 +44,10 @@
from sqlalchemy.engine import Dialect
from sqlalchemy.sql.elements import ColumnClause
from sqlalchemy.sql.elements import quoted_name
- from sqlalchemy.sql.functions import Function
from sqlalchemy.sql.schema import Constraint
from sqlalchemy.sql.type_api import TypeEngine
+ from ..ddl.base import _ServerDefaultType
from ..ddl.impl import DefaultImpl
@@ -449,13 +448,15 @@ def _create(self, op_impl: DefaultImpl) -> None:
try:
op_impl._exec(
- _insert_inline(self.new_table).from_select(
+ self.new_table.insert()
+ .inline()
+ .from_select(
list(
k
for k, transfer in self.column_transfers.items()
if "expr" in transfer
),
- _select(
+ select(
*[
transfer["expr"]
for transfer in self.column_transfers.values()
@@ -484,7 +485,9 @@ def alter_column(
table_name: str,
column_name: str,
nullable: Optional[bool] = None,
- server_default: Optional[Union[Function[Any], str, bool]] = False,
+ server_default: Union[
+ _ServerDefaultType, None, Literal[False]
+ ] = False,
name: Optional[str] = None,
type_: Optional[TypeEngine] = None,
autoincrement: Optional[Union[bool, Literal["auto"]]] = None,
diff --git a/libs/alembic/operations/ops.py b/libs/alembic/operations/ops.py
index 60b856a8f7..7eda50d672 100644
--- a/libs/alembic/operations/ops.py
+++ b/libs/alembic/operations/ops.py
@@ -1,6 +1,8 @@
from __future__ import annotations
from abc import abstractmethod
+import os
+import pathlib
import re
from typing import Any
from typing import Callable
@@ -35,13 +37,10 @@
from sqlalchemy.sql.elements import conv
from sqlalchemy.sql.elements import quoted_name
from sqlalchemy.sql.elements import TextClause
- from sqlalchemy.sql.functions import Function
from sqlalchemy.sql.schema import CheckConstraint
from sqlalchemy.sql.schema import Column
- from sqlalchemy.sql.schema import Computed
from sqlalchemy.sql.schema import Constraint
from sqlalchemy.sql.schema import ForeignKeyConstraint
- from sqlalchemy.sql.schema import Identity
from sqlalchemy.sql.schema import Index
from sqlalchemy.sql.schema import MetaData
from sqlalchemy.sql.schema import PrimaryKeyConstraint
@@ -52,6 +51,7 @@
from sqlalchemy.sql.type_api import TypeEngine
from ..autogenerate.rewriter import Rewriter
+ from ..ddl.base import _ServerDefaultType
from ..runtime.migration import MigrationContext
from ..script.revision import _RevIdType
@@ -141,12 +141,14 @@ def __init__(
type_: Optional[str] = None,
*,
schema: Optional[str] = None,
+ if_exists: Optional[bool] = None,
_reverse: Optional[AddConstraintOp] = None,
) -> None:
self.constraint_name = constraint_name
self.table_name = table_name
self.constraint_type = type_
self.schema = schema
+ self.if_exists = if_exists
self._reverse = _reverse
def reverse(self) -> AddConstraintOp:
@@ -204,6 +206,7 @@ def drop_constraint(
type_: Optional[str] = None,
*,
schema: Optional[str] = None,
+ if_exists: Optional[bool] = None,
) -> None:
r"""Drop a constraint of the given name, typically via DROP CONSTRAINT.
@@ -215,10 +218,20 @@ def drop_constraint(
quoting of the schema outside of the default behavior, use
the SQLAlchemy construct
:class:`~sqlalchemy.sql.elements.quoted_name`.
+ :param if_exists: If True, adds IF EXISTS operator when
+ dropping the constraint
+
+ .. versionadded:: 1.16.0
"""
- op = cls(constraint_name, table_name, type_=type_, schema=schema)
+ op = cls(
+ constraint_name,
+ table_name,
+ type_=type_,
+ schema=schema,
+ if_exists=if_exists,
+ )
return operations.invoke(op)
@classmethod
@@ -933,7 +946,7 @@ def create_index(
operations: Operations,
index_name: Optional[str],
table_name: str,
- columns: Sequence[Union[str, TextClause, Function[Any]]],
+ columns: Sequence[Union[str, TextClause, ColumnElement[Any]]],
*,
schema: Optional[str] = None,
unique: bool = False,
@@ -1682,7 +1695,9 @@ def __init__(
*,
schema: Optional[str] = None,
existing_type: Optional[Any] = None,
- existing_server_default: Any = False,
+ existing_server_default: Union[
+ _ServerDefaultType, None, Literal[False]
+ ] = False,
existing_nullable: Optional[bool] = None,
existing_comment: Optional[str] = None,
modify_nullable: Optional[bool] = None,
@@ -1841,14 +1856,16 @@ def alter_column(
*,
nullable: Optional[bool] = None,
comment: Optional[Union[str, Literal[False]]] = False,
- server_default: Any = False,
+ server_default: Union[
+ _ServerDefaultType, None, Literal[False]
+ ] = False,
new_column_name: Optional[str] = None,
type_: Optional[Union[TypeEngine[Any], Type[TypeEngine[Any]]]] = None,
existing_type: Optional[
Union[TypeEngine[Any], Type[TypeEngine[Any]]]
] = None,
- existing_server_default: Optional[
- Union[str, bool, Identity, Computed]
+ existing_server_default: Union[
+ _ServerDefaultType, None, Literal[False]
] = False,
existing_nullable: Optional[bool] = None,
existing_comment: Optional[str] = None,
@@ -1964,14 +1981,16 @@ def batch_alter_column(
*,
nullable: Optional[bool] = None,
comment: Optional[Union[str, Literal[False]]] = False,
- server_default: Any = False,
+ server_default: Union[
+ _ServerDefaultType, None, Literal[False]
+ ] = False,
new_column_name: Optional[str] = None,
type_: Optional[Union[TypeEngine[Any], Type[TypeEngine[Any]]]] = None,
existing_type: Optional[
Union[TypeEngine[Any], Type[TypeEngine[Any]]]
] = None,
- existing_server_default: Optional[
- Union[str, bool, Identity, Computed]
+ existing_server_default: Union[
+ _ServerDefaultType, None, Literal[False]
] = False,
existing_nullable: Optional[bool] = None,
existing_comment: Optional[str] = None,
@@ -2033,16 +2052,24 @@ def __init__(
column: Column[Any],
*,
schema: Optional[str] = None,
+ if_not_exists: Optional[bool] = None,
+ inline_references: Optional[bool] = None,
+ inline_primary_key: Optional[bool] = None,
**kw: Any,
) -> None:
super().__init__(table_name, schema=schema)
self.column = column
+ self.if_not_exists = if_not_exists
+ self.inline_references = inline_references
+ self.inline_primary_key = inline_primary_key
self.kw = kw
def reverse(self) -> DropColumnOp:
- return DropColumnOp.from_column_and_tablename(
+ op = DropColumnOp.from_column_and_tablename(
self.schema, self.table_name, self.column
)
+ op.if_exists = self.if_not_exists
+ return op
def to_diff_tuple(
self,
@@ -2073,6 +2100,9 @@ def add_column(
column: Column[Any],
*,
schema: Optional[str] = None,
+ if_not_exists: Optional[bool] = None,
+ inline_references: Optional[bool] = None,
+ inline_primary_key: Optional[bool] = None,
) -> None:
"""Issue an "add column" instruction using the current
migration context.
@@ -2087,36 +2117,77 @@ def add_column(
The :meth:`.Operations.add_column` method typically corresponds
to the SQL command "ALTER TABLE... ADD COLUMN". Within the scope
of this command, the column's name, datatype, nullability,
- and optional server-generated defaults may be indicated.
+ and optional server-generated defaults may be indicated. Options
+ also exist for control of single-column primary key and foreign key
+ constraints to be generated.
.. note::
- With the exception of NOT NULL constraints or single-column FOREIGN
- KEY constraints, other kinds of constraints such as PRIMARY KEY,
- UNIQUE or CHECK constraints **cannot** be generated using this
- method; for these constraints, refer to operations such as
- :meth:`.Operations.create_primary_key` and
- :meth:`.Operations.create_check_constraint`. In particular, the
- following :class:`~sqlalchemy.schema.Column` parameters are
- **ignored**:
-
- * :paramref:`~sqlalchemy.schema.Column.primary_key` - SQL databases
- typically do not support an ALTER operation that can add
- individual columns one at a time to an existing primary key
- constraint, therefore it's less ambiguous to use the
- :meth:`.Operations.create_primary_key` method, which assumes no
- existing primary key constraint is present.
+ Not all contraint types may be indicated with this directive.
+ NOT NULL, FOREIGN KEY, and CHECK are honored, PRIMARY KEY
+ is conditionally honored, UNIQUE
+ is currently not.
+
+ As of 1.18.2, the following :class:`~sqlalchemy.schema.Column`
+ parameters are **ignored**:
+
* :paramref:`~sqlalchemy.schema.Column.unique` - use the
:meth:`.Operations.create_unique_constraint` method
* :paramref:`~sqlalchemy.schema.Column.index` - use the
:meth:`.Operations.create_index` method
+ **PRIMARY KEY support**
+
+ The provided :class:`~sqlalchemy.schema.Column` object may include a
+ ``primary_key=True`` directive, indicating the column intends to be
+ part of a primary key constraint. However by default, the inline
+ "PRIMARY KEY" directive is not emitted, and it's assumed that a
+ separate :meth:`.Operations.create_primary_key` directive will be used
+ to create this constraint, which may potentially include other columns
+ as well as have an explicit name. To instead render an inline
+ "PRIMARY KEY" directive, the
+ :paramref:`.AddColumnOp.inline_primary_key` parameter may be indicated
+ at the same time as the ``primary_key`` parameter (both are needed)::
+
+ from alembic import op
+ from sqlalchemy import Column, INTEGER
+
+ op.add_column(
+ "organization",
+ Column("id", INTEGER, primary_key=True),
+ inline_primary_key=True
+ )
+
+ The ``primary_key=True`` parameter on
+ :class:`~sqlalchemy.schema.Column` also indicates behaviors such as
+ using the ``SERIAL`` datatype with the PostgreSQL database, which is
+ why two separate, independent parameters are provided to support all
+ combinations.
+
+ .. versionadded:: 1.18.4 Added
+ :paramref:`.AddColumnOp.inline_primary_key`
+ to control use of the ``PRIMARY KEY`` inline directive.
+
+ **FOREIGN KEY support**
The provided :class:`~sqlalchemy.schema.Column` object may include a
:class:`~sqlalchemy.schema.ForeignKey` constraint directive,
- referencing a remote table name. For this specific type of constraint,
- Alembic will automatically emit a second ALTER statement in order to
- add the single-column FOREIGN KEY constraint separately::
+ referencing a remote table name. By default, Alembic will automatically
+ emit a second ALTER statement in order to add the single-column FOREIGN
+ KEY constraint separately::
+
+ from alembic import op
+ from sqlalchemy import Column, INTEGER, ForeignKey
+
+ op.add_column(
+ "organization",
+ Column("account_id", INTEGER, ForeignKey("accounts.id")),
+ )
+
+ To render the FOREIGN KEY constraint inline within the ADD COLUMN
+ directive, use the ``inline_references`` parameter. This can improve
+ performance on large tables since the constraint is marked as valid
+ immediately for nullable columns::
from alembic import op
from sqlalchemy import Column, INTEGER, ForeignKey
@@ -2124,8 +2195,11 @@ def add_column(
op.add_column(
"organization",
Column("account_id", INTEGER, ForeignKey("accounts.id")),
+ inline_references=True,
)
+ **Indicating server side defaults**
+
The column argument passed to :meth:`.Operations.add_column` is a
:class:`~sqlalchemy.schema.Column` construct, used in the same way it's
used in SQLAlchemy. In particular, values or functions to be indicated
@@ -2149,10 +2223,38 @@ def add_column(
quoting of the schema outside of the default behavior, use
the SQLAlchemy construct
:class:`~sqlalchemy.sql.elements.quoted_name`.
+ :param if_not_exists: If True, adds ``IF NOT EXISTS`` operator
+ when creating the new column for compatible dialects
+
+ .. versionadded:: 1.16.0
+
+ :param inline_references: If True, renders ``FOREIGN KEY`` constraints
+ inline within the ``ADD COLUMN`` directive using ``REFERENCES``
+ syntax, rather than as a separate ``ALTER TABLE ADD CONSTRAINT``
+ statement. This is supported by PostgreSQL, Oracle, MySQL 5.7+, and
+ MariaDB 10.5+.
+
+ .. versionadded:: 1.18.2
+
+ :param inline_primary_key: If True, renders the ``PRIMARY KEY`` phrase
+ inline within the ``ADD COLUMN`` directive. When not present or
+ False, ``PRIMARY KEY`` is not emitted; it is assumed that the
+ migration script will include an additional
+ :meth:`.Operations.create_primary_key` directive to create a full
+ primary key constraint.
+
+ .. versionadded:: 1.18.4
"""
- op = cls(table_name, column, schema=schema)
+ op = cls(
+ table_name,
+ column,
+ schema=schema,
+ if_not_exists=if_not_exists,
+ inline_references=inline_references,
+ inline_primary_key=inline_primary_key,
+ )
return operations.invoke(op)
@classmethod
@@ -2163,6 +2265,9 @@ def batch_add_column(
*,
insert_before: Optional[str] = None,
insert_after: Optional[str] = None,
+ if_not_exists: Optional[bool] = None,
+ inline_references: Optional[bool] = None,
+ inline_primary_key: Optional[bool] = None,
) -> None:
"""Issue an "add column" instruction using the current
batch migration context.
@@ -2183,6 +2288,9 @@ def batch_add_column(
operations.impl.table_name,
column,
schema=operations.impl.schema,
+ if_not_exists=if_not_exists,
+ inline_references=inline_references,
+ inline_primary_key=inline_primary_key,
**kw,
)
return operations.invoke(op)
@@ -2199,12 +2307,14 @@ def __init__(
column_name: str,
*,
schema: Optional[str] = None,
+ if_exists: Optional[bool] = None,
_reverse: Optional[AddColumnOp] = None,
**kw: Any,
) -> None:
super().__init__(table_name, schema=schema)
self.column_name = column_name
self.kw = kw
+ self.if_exists = if_exists
self._reverse = _reverse
def to_diff_tuple(
@@ -2224,9 +2334,11 @@ def reverse(self) -> AddColumnOp:
"original column is not present"
)
- return AddColumnOp.from_column_and_tablename(
+ op = AddColumnOp.from_column_and_tablename(
self.schema, self.table_name, self._reverse.column
)
+ op.if_not_exists = self.if_exists
+ return op
@classmethod
def from_column_and_tablename(
@@ -2273,6 +2385,11 @@ def drop_column(
quoting of the schema outside of the default behavior, use
the SQLAlchemy construct
:class:`~sqlalchemy.sql.elements.quoted_name`.
+ :param if_exists: If True, adds IF EXISTS operator when
+ dropping the new column for compatible dialects
+
+ .. versionadded:: 1.16.0
+
:param mssql_drop_check: Optional boolean. When ``True``, on
Microsoft SQL Server only, first
drop the CHECK constraint on the column using a
@@ -2294,7 +2411,6 @@ def drop_column(
then exec's a separate DROP CONSTRAINT for that default. Only
works if the column has exactly one FK constraint which refers to
it, at the moment.
-
"""
op = cls(table_name, column_name, schema=schema, **kw)
@@ -2709,7 +2825,7 @@ def __init__(
head: Optional[str] = None,
splice: Optional[bool] = None,
branch_label: Optional[_RevIdType] = None,
- version_path: Optional[str] = None,
+ version_path: Union[str, os.PathLike[str], None] = None,
depends_on: Optional[_RevIdType] = None,
) -> None:
self.rev_id = rev_id
@@ -2718,7 +2834,9 @@ def __init__(
self.head = head
self.splice = splice
self.branch_label = branch_label
- self.version_path = version_path
+ self.version_path = (
+ pathlib.Path(version_path).as_posix() if version_path else None
+ )
self.depends_on = depends_on
self.upgrade_ops = upgrade_ops
self.downgrade_ops = downgrade_ops
diff --git a/libs/alembic/operations/toimpl.py b/libs/alembic/operations/toimpl.py
index 4b960049c7..85b9d8a324 100644
--- a/libs/alembic/operations/toimpl.py
+++ b/libs/alembic/operations/toimpl.py
@@ -8,7 +8,7 @@
from . import ops
from .base import Operations
from ..util.sqla_compat import _copy
-from ..util.sqla_compat import sqla_14
+from ..util.sqla_compat import sqla_2
if TYPE_CHECKING:
from sqlalchemy.sql.schema import Table
@@ -50,6 +50,11 @@ def _count_constraint(constraint):
if _count_constraint(constraint):
operations.impl.drop_constraint(constraint)
+ # some weird pyright quirk here, these have Literal[False]
+ # in their types, not sure why pyright thinks they could be True
+ assert existing_server_default is not True # type: ignore[comparison-overlap] # noqa: E501
+ assert comment is not True # type: ignore[comparison-overlap]
+
operations.impl.alter_column(
table_name,
column_name,
@@ -81,9 +86,6 @@ def _count_constraint(constraint):
def drop_table(operations: "Operations", operation: "ops.DropTableOp") -> None:
kw = {}
if operation.if_exists is not None:
- if not sqla_14:
- raise NotImplementedError("SQLAlchemy 1.4+ required")
-
kw["if_exists"] = operation.if_exists
operations.impl.drop_table(
operation.to_table(operations.migration_context), **kw
@@ -96,7 +98,11 @@ def drop_column(
) -> None:
column = operation.to_column(operations.migration_context)
operations.impl.drop_column(
- operation.table_name, column, schema=operation.schema, **operation.kw
+ operation.table_name,
+ column,
+ schema=operation.schema,
+ if_exists=operation.if_exists,
+ **operation.kw,
)
@@ -107,9 +113,6 @@ def create_index(
idx = operation.to_index(operations.migration_context)
kw = {}
if operation.if_not_exists is not None:
- if not sqla_14:
- raise NotImplementedError("SQLAlchemy 1.4+ required")
-
kw["if_not_exists"] = operation.if_not_exists
operations.impl.create_index(idx, **kw)
@@ -118,9 +121,6 @@ def create_index(
def drop_index(operations: "Operations", operation: "ops.DropIndexOp") -> None:
kw = {}
if operation.if_exists is not None:
- if not sqla_14:
- raise NotImplementedError("SQLAlchemy 1.4+ required")
-
kw["if_exists"] = operation.if_exists
operations.impl.drop_index(
@@ -135,9 +135,6 @@ def create_table(
) -> "Table":
kw = {}
if operation.if_not_exists is not None:
- if not sqla_14:
- raise NotImplementedError("SQLAlchemy 1.4+ required")
-
kw["if_not_exists"] = operation.if_not_exists
table = operation.to_table(operations.migration_context)
operations.impl.create_table(table, **kw)
@@ -175,15 +172,35 @@ def add_column(operations: "Operations", operation: "ops.AddColumnOp") -> None:
column = operation.column
schema = operation.schema
kw = operation.kw
+ inline_references = operation.inline_references
+ inline_primary_key = operation.inline_primary_key
if column.table is not None:
column = _copy(column)
t = operations.schema_obj.table(table_name, column, schema=schema)
- operations.impl.add_column(table_name, column, schema=schema, **kw)
+ operations.impl.add_column(
+ table_name,
+ column,
+ schema=schema,
+ if_not_exists=operation.if_not_exists,
+ inline_references=inline_references,
+ inline_primary_key=inline_primary_key,
+ **kw,
+ )
for constraint in t.constraints:
if not isinstance(constraint, sa_schema.PrimaryKeyConstraint):
+ # Skip ForeignKeyConstraint if it was rendered inline
+ # This only happens when inline_references=True AND there's exactly
+ # one FK AND the constraint is single-column
+ if (
+ inline_references
+ and isinstance(constraint, sa_schema.ForeignKeyConstraint)
+ and len(column.foreign_keys) == 1
+ and len(constraint.columns) == 1
+ ):
+ continue
operations.impl.add_constraint(constraint)
for index in t.indexes:
operations.impl.create_index(index)
@@ -210,13 +227,19 @@ def create_constraint(
def drop_constraint(
operations: "Operations", operation: "ops.DropConstraintOp"
) -> None:
+ kw = {}
+ if operation.if_exists is not None:
+ if not sqla_2:
+ raise NotImplementedError("SQLAlchemy 2.0 required")
+ kw["if_exists"] = operation.if_exists
operations.impl.drop_constraint(
operations.schema_obj.generic_constraint(
operation.constraint_name,
operation.table_name,
operation.constraint_type,
schema=operation.schema,
- )
+ ),
+ **kw,
)
diff --git a/libs/alembic/runtime/environment.py b/libs/alembic/runtime/environment.py
index a30972ec91..5817e2d9fd 100644
--- a/libs/alembic/runtime/environment.py
+++ b/libs/alembic/runtime/environment.py
@@ -3,7 +3,6 @@
from typing import Any
from typing import Callable
from typing import Collection
-from typing import ContextManager
from typing import Dict
from typing import List
from typing import Mapping
@@ -18,6 +17,7 @@
from sqlalchemy.sql.schema import Column
from sqlalchemy.sql.schema import FetchedValue
+from typing_extensions import ContextManager
from typing_extensions import Literal
from .migration import _ProxyTransaction
@@ -338,7 +338,7 @@ def get_tag_argument(self) -> Optional[str]:
line.
"""
- return self.context_opts.get("tag", None) # type: ignore[no-any-return] # noqa: E501
+ return self.context_opts.get("tag", None)
@overload
def get_x_argument(self, as_dictionary: Literal[False]) -> List[str]: ...
@@ -441,6 +441,7 @@ def configure(
sqlalchemy_module_prefix: str = "sa.",
user_module_prefix: Optional[str] = None,
on_version_apply: Optional[OnVersionApplyFn] = None,
+ autogenerate_plugins: Sequence[str] | None = None,
**kw: Any,
) -> None:
"""Configure a :class:`.MigrationContext` within this
@@ -860,6 +861,25 @@ def process_revision_directives(context, revision, directives):
:paramref:`.command.revision.process_revision_directives`
+ :param autogenerate_plugins: A list of string names of "plugins" that
+ should participate in this autogenerate run. Defaults to the list
+ ``["alembic.autogenerate.*"]``, which indicates that Alembic's default
+ autogeneration plugins will be used.
+
+ See the section :ref:`plugins_autogenerate` for complete background
+ on how to use this parameter.
+
+ .. versionadded:: 1.18.0 Added a new plugin system for autogenerate
+ compare directives.
+
+ .. seealso::
+
+ :ref:`plugins_autogenerate` - background on enabling/disabling
+ autogenerate plugins
+
+ :ref:`alembic.plugins.toplevel` - Introduction and documentation
+ to the plugin system
+
Parameters specific to individual backends:
:param mssql_batch_separator: The "batch separator" which will
@@ -903,6 +923,9 @@ def process_revision_directives(context, revision, directives):
opts["process_revision_directives"] = process_revision_directives
opts["on_version_apply"] = util.to_tuple(on_version_apply, default=())
+ if autogenerate_plugins is not None:
+ opts["autogenerate_plugins"] = autogenerate_plugins
+
if render_item is not None:
opts["render_item"] = render_item
opts["compare_type"] = compare_type
@@ -976,7 +999,7 @@ def static_output(self, text: str) -> None:
def begin_transaction(
self,
- ) -> Union[_ProxyTransaction, ContextManager[None]]:
+ ) -> Union[_ProxyTransaction, ContextManager[None, Optional[bool]]]:
"""Return a context manager that will
enclose an operation within a "transaction",
as defined by the environment's offline
diff --git a/libs/alembic/runtime/migration.py b/libs/alembic/runtime/migration.py
index 28f01c3b30..3fccf22a67 100644
--- a/libs/alembic/runtime/migration.py
+++ b/libs/alembic/runtime/migration.py
@@ -11,7 +11,6 @@
from typing import Callable
from typing import cast
from typing import Collection
-from typing import ContextManager
from typing import Dict
from typing import Iterable
from typing import Iterator
@@ -22,17 +21,17 @@
from typing import TYPE_CHECKING
from typing import Union
-from sqlalchemy import Column
from sqlalchemy import literal_column
+from sqlalchemy import select
from sqlalchemy.engine import Engine
from sqlalchemy.engine import url as sqla_url
from sqlalchemy.engine.strategies import MockEngineStrategy
+from typing_extensions import ContextManager
from .. import ddl
from .. import util
from ..util import sqla_compat
from ..util.compat import EncodedIO
-from ..util.sqla_compat import _select
if TYPE_CHECKING:
from sqlalchemy.engine import Dialect
@@ -175,7 +174,11 @@ def __init__(
opts["output_encoding"],
)
else:
- self.output_buffer = opts.get("output_buffer", sys.stdout)
+ self.output_buffer = opts.get(
+ "output_buffer", sys.stdout
+ ) # type:ignore[assignment] # noqa: E501
+
+ self.transactional_ddl = transactional_ddl
self._user_compare_type = opts.get("compare_type", True)
self._user_compare_server_default = opts.get(
@@ -368,7 +371,7 @@ def upgrade():
def begin_transaction(
self, _per_migration: bool = False
- ) -> Union[_ProxyTransaction, ContextManager[None]]:
+ ) -> Union[_ProxyTransaction, ContextManager[None, Optional[bool]]]:
"""Begin a logical transaction for migration operations.
This method is used within an ``env.py`` script to demarcate where
@@ -534,7 +537,7 @@ def get_current_heads(self) -> Tuple[str, ...]:
return tuple(
row[0]
for row in self.connection.execute(
- _select(self._version.c.version_num)
+ select(self._version.c.version_num)
)
)
@@ -702,54 +705,6 @@ def config(self) -> Optional[Config]:
else:
return None
- def _compare_type(
- self, inspector_column: Column[Any], metadata_column: Column
- ) -> bool:
- if self._user_compare_type is False:
- return False
-
- if callable(self._user_compare_type):
- user_value = self._user_compare_type(
- self,
- inspector_column,
- metadata_column,
- inspector_column.type,
- metadata_column.type,
- )
- if user_value is not None:
- return user_value
-
- return self.impl.compare_type(inspector_column, metadata_column)
-
- def _compare_server_default(
- self,
- inspector_column: Column[Any],
- metadata_column: Column[Any],
- rendered_metadata_default: Optional[str],
- rendered_column_default: Optional[str],
- ) -> bool:
- if self._user_compare_server_default is False:
- return False
-
- if callable(self._user_compare_server_default):
- user_value = self._user_compare_server_default(
- self,
- inspector_column,
- metadata_column,
- rendered_column_default,
- metadata_column.server_default,
- rendered_metadata_default,
- )
- if user_value is not None:
- return user_value
-
- return self.impl.compare_server_default(
- inspector_column,
- metadata_column,
- rendered_metadata_default,
- rendered_column_default,
- )
-
class HeadMaintainer:
def __init__(self, context: MigrationContext, heads: Any) -> None:
diff --git a/libs/alembic/runtime/plugins.py b/libs/alembic/runtime/plugins.py
new file mode 100644
index 0000000000..be1d590f55
--- /dev/null
+++ b/libs/alembic/runtime/plugins.py
@@ -0,0 +1,179 @@
+from __future__ import annotations
+
+from importlib import metadata
+import logging
+import re
+from types import ModuleType
+from typing import Callable
+from typing import Pattern
+from typing import TYPE_CHECKING
+
+from .. import util
+from ..util import DispatchPriority
+from ..util import PriorityDispatcher
+
+if TYPE_CHECKING:
+ from ..util import PriorityDispatchResult
+
+_all_plugins = {}
+
+
+log = logging.getLogger(__name__)
+
+
+class Plugin:
+ """Describe a series of functions that are pulled in as a plugin.
+
+ This is initially to provide for portable lists of autogenerate
+ comparison functions, however the setup for a plugin can run any
+ other kinds of global registration as well.
+
+ .. versionadded:: 1.18.0
+
+ """
+
+ def __init__(self, name: str):
+ self.name = name
+ log.info("setup plugin %s", name)
+ if name in _all_plugins:
+ raise ValueError(f"A plugin named {name} is already registered")
+ _all_plugins[name] = self
+ self.autogenerate_comparators = PriorityDispatcher()
+
+ def remove(self) -> None:
+ """remove this plugin"""
+
+ del _all_plugins[self.name]
+
+ def add_autogenerate_comparator(
+ self,
+ fn: Callable[..., PriorityDispatchResult],
+ compare_target: str,
+ compare_element: str | None = None,
+ *,
+ qualifier: str = "default",
+ priority: DispatchPriority = DispatchPriority.MEDIUM,
+ ) -> None:
+ """Register an autogenerate comparison function.
+
+ See the section :ref:`plugins_registering_autogenerate` for detailed
+ examples on how to use this method.
+
+ :param fn: The comparison function to register. The function receives
+ arguments specific to the type of comparison being performed and
+ should return a :class:`.PriorityDispatchResult` value.
+
+ :param compare_target: The type of comparison being performed
+ (e.g., ``"table"``, ``"column"``, ``"type"``).
+
+ :param compare_element: Optional sub-element being compared within
+ the target type.
+
+ :param qualifier: Database dialect qualifier. Use ``"default"`` for
+ all dialects, or specify a dialect name like ``"postgresql"`` to
+ register a dialect-specific handler. Defaults to ``"default"``.
+
+ :param priority: Execution priority for this comparison function.
+ Functions are executed in priority order from
+ :attr:`.DispatchPriority.FIRST` to :attr:`.DispatchPriority.LAST`.
+ Defaults to :attr:`.DispatchPriority.MEDIUM`.
+
+ """
+ self.autogenerate_comparators.dispatch_for(
+ compare_target,
+ subgroup=compare_element,
+ priority=priority,
+ qualifier=qualifier,
+ )(fn)
+
+ @classmethod
+ def populate_autogenerate_priority_dispatch(
+ cls, comparators: PriorityDispatcher, include_plugins: list[str]
+ ) -> None:
+ """Populate all current autogenerate comparison functions into
+ a given PriorityDispatcher."""
+
+ exclude: set[Pattern[str]] = set()
+ include: dict[str, Pattern[str]] = {}
+
+ matched_expressions: set[str] = set()
+
+ for name in include_plugins:
+ if name.startswith("~"):
+ exclude.add(_make_re(name[1:]))
+ else:
+ include[name] = _make_re(name)
+
+ for plugin in _all_plugins.values():
+ if any(excl.match(plugin.name) for excl in exclude):
+ continue
+
+ include_matches = [
+ incl for incl in include if include[incl].match(plugin.name)
+ ]
+ if not include_matches:
+ continue
+ else:
+ matched_expressions.update(include_matches)
+
+ log.info("setting up autogenerate plugin %s", plugin.name)
+ comparators.populate_with(plugin.autogenerate_comparators)
+
+ never_matched = set(include).difference(matched_expressions)
+ if never_matched:
+ raise util.CommandError(
+ f"Did not locate plugins: {', '.join(never_matched)}"
+ )
+
+ @classmethod
+ def setup_plugin_from_module(cls, module: ModuleType, name: str) -> None:
+ """Call the ``setup()`` function of a plugin module, identified by
+ passing the module object itself.
+
+ E.g.::
+
+ from alembic.runtime.plugins import Plugin
+ import myproject.alembic_plugin
+
+ # Register the plugin manually
+ Plugin.setup_plugin_from_module(
+ myproject.alembic_plugin,
+ "myproject.custom_operations"
+ )
+
+ This will generate a new :class:`.Plugin` object with the given
+ name, which will register itself in the global list of plugins.
+ Then the module's ``setup()`` function is invoked, passing that
+ :class:`.Plugin` object.
+
+ This exact process is invoked automatically at import time for any
+ plugin module that is published via the ``alembic.plugins`` entrypoint.
+
+ """
+ module.setup(Plugin(name))
+
+
+def _make_re(name: str) -> Pattern[str]:
+ tokens = name.split(".")
+
+ reg = r""
+ for token in tokens:
+ if token == "*":
+ reg += r"\..+?"
+ elif token.isidentifier():
+ reg += r"\." + token
+ else:
+ raise ValueError(f"Invalid plugin expression {name!r}")
+
+ # omit leading r'\.'
+ return re.compile(f"^{reg[2:]}$")
+
+
+def _setup() -> None:
+ # setup third party plugins
+ for entrypoint in metadata.entry_points(group="alembic.plugins"):
+ for mod in entrypoint.load():
+ Plugin.setup_plugin_from_module(mod, entrypoint.name)
+
+
+_setup()
diff --git a/libs/alembic/script/base.py b/libs/alembic/script/base.py
index 30df6ddb2b..f841708598 100644
--- a/libs/alembic/script/base.py
+++ b/libs/alembic/script/base.py
@@ -3,6 +3,7 @@
from contextlib import contextmanager
import datetime
import os
+from pathlib import Path
import re
import shutil
import sys
@@ -11,7 +12,6 @@
from typing import cast
from typing import Iterator
from typing import List
-from typing import Mapping
from typing import Optional
from typing import Sequence
from typing import Set
@@ -25,6 +25,7 @@
from ..runtime import migration
from ..util import compat
from ..util import not_none
+from ..util.pyfiles import _preserving_path_as_str
if TYPE_CHECKING:
from .revision import _GetRevArg
@@ -32,16 +33,13 @@
from .revision import Revision
from ..config import Config
from ..config import MessagingOptions
+ from ..config import PostWriteHookConfig
from ..runtime.migration import RevisionStep
from ..runtime.migration import StampStep
try:
- if compat.py39:
- from zoneinfo import ZoneInfo
- from zoneinfo import ZoneInfoNotFoundError
- else:
- from backports.zoneinfo import ZoneInfo # type: ignore[import-not-found,no-redef] # noqa: E501
- from backports.zoneinfo import ZoneInfoNotFoundError # type: ignore[no-redef] # noqa: E501
+ from zoneinfo import ZoneInfo
+ from zoneinfo import ZoneInfoNotFoundError
except ImportError:
ZoneInfo = None # type: ignore[assignment, misc]
@@ -50,9 +48,6 @@
_legacy_rev = re.compile(r"([a-f0-9]+)\.py$")
_slug_re = re.compile(r"\w+")
_default_file_template = "%(rev)s_%(slug)s"
-_split_on_space_comma = re.compile(r", *|(?: +)")
-
-_split_on_space_comma_colon = re.compile(r", *|(?: +)|\:")
class ScriptDirectory:
@@ -77,40 +72,55 @@ class ScriptDirectory:
def __init__(
self,
- dir: str, # noqa
+ dir: Union[str, os.PathLike[str]], # noqa: A002
file_template: str = _default_file_template,
truncate_slug_length: Optional[int] = 40,
- version_locations: Optional[List[str]] = None,
+ version_locations: Optional[
+ Sequence[Union[str, os.PathLike[str]]]
+ ] = None,
sourceless: bool = False,
output_encoding: str = "utf-8",
timezone: Optional[str] = None,
- hook_config: Optional[Mapping[str, str]] = None,
+ hooks: list[PostWriteHookConfig] = [],
recursive_version_locations: bool = False,
messaging_opts: MessagingOptions = cast(
"MessagingOptions", util.EMPTY_DICT
),
) -> None:
- self.dir = dir
+ self.dir = _preserving_path_as_str(dir)
+ self.version_locations = [
+ _preserving_path_as_str(p) for p in version_locations or ()
+ ]
self.file_template = file_template
- self.version_locations = version_locations
self.truncate_slug_length = truncate_slug_length or 40
self.sourceless = sourceless
self.output_encoding = output_encoding
self.revision_map = revision.RevisionMap(self._load_revisions)
self.timezone = timezone
- self.hook_config = hook_config
+ self.hooks = hooks
self.recursive_version_locations = recursive_version_locations
self.messaging_opts = messaging_opts
if not os.access(dir, os.F_OK):
raise util.CommandError(
- "Path doesn't exist: %r. Please use "
+ f"Path doesn't exist: {dir}. Please use "
"the 'init' command to create a new "
- "scripts folder." % os.path.abspath(dir)
+ "scripts folder."
)
@property
def versions(self) -> str:
+ """return a single version location based on the sole path passed
+ within version_locations.
+
+ If multiple version locations are configured, an error is raised.
+
+
+ """
+ return str(self._singular_version_location)
+
+ @util.memoized_property
+ def _singular_version_location(self) -> Path:
loc = self._version_locations
if len(loc) > 1:
raise util.CommandError("Multiple version_locations present")
@@ -118,40 +128,31 @@ def versions(self) -> str:
return loc[0]
@util.memoized_property
- def _version_locations(self) -> Sequence[str]:
+ def _version_locations(self) -> Sequence[Path]:
if self.version_locations:
return [
- os.path.abspath(util.coerce_resource_to_filename(location))
+ util.coerce_resource_to_filename(location).absolute()
for location in self.version_locations
]
else:
- return (os.path.abspath(os.path.join(self.dir, "versions")),)
+ return [Path(self.dir, "versions").absolute()]
def _load_revisions(self) -> Iterator[Script]:
- if self.version_locations:
- paths = [
- vers
- for vers in self._version_locations
- if os.path.exists(vers)
- ]
- else:
- paths = [self.versions]
+ paths = [vers for vers in self._version_locations if vers.exists()]
dupes = set()
for vers in paths:
for file_path in Script._list_py_dir(self, vers):
- real_path = os.path.realpath(file_path)
+ real_path = file_path.resolve()
if real_path in dupes:
util.warn(
- "File %s loaded twice! ignoring. Please ensure "
- "version_locations is unique." % real_path
+ f"File {real_path} loaded twice! ignoring. "
+ "Please ensure version_locations is unique."
)
continue
dupes.add(real_path)
- filename = os.path.basename(real_path)
- dir_name = os.path.dirname(real_path)
- script = Script._from_filename(self, dir_name, filename)
+ script = Script._from_path(self, real_path)
if script is None:
continue
yield script
@@ -165,78 +166,36 @@ def from_config(cls, config: Config) -> ScriptDirectory:
present.
"""
- script_location = config.get_main_option("script_location")
+ script_location = config.get_alembic_option("script_location")
if script_location is None:
raise util.CommandError(
- "No 'script_location' key " "found in configuration."
+ "No 'script_location' key found in configuration."
)
truncate_slug_length: Optional[int]
- tsl = config.get_main_option("truncate_slug_length")
+ tsl = config.get_alembic_option("truncate_slug_length")
if tsl is not None:
truncate_slug_length = int(tsl)
else:
truncate_slug_length = None
- version_locations_str = config.get_main_option("version_locations")
- version_locations: Optional[List[str]]
- if version_locations_str:
- version_path_separator = config.get_main_option(
- "version_path_separator"
- )
-
- split_on_path = {
- None: None,
- "space": " ",
- "newline": "\n",
- "os": os.pathsep,
- ":": ":",
- ";": ";",
- }
-
- try:
- split_char: Optional[str] = split_on_path[
- version_path_separator
- ]
- except KeyError as ke:
- raise ValueError(
- "'%s' is not a valid value for "
- "version_path_separator; "
- "expected 'space', 'newline', 'os', ':', ';'"
- % version_path_separator
- ) from ke
- else:
- if split_char is None:
- # legacy behaviour for backwards compatibility
- version_locations = _split_on_space_comma.split(
- version_locations_str
- )
- else:
- version_locations = [
- x.strip()
- for x in version_locations_str.split(split_char)
- if x
- ]
- else:
- version_locations = None
-
- prepend_sys_path = config.get_main_option("prepend_sys_path")
+ prepend_sys_path = config.get_prepend_sys_paths_list()
if prepend_sys_path:
- sys.path[:0] = list(
- _split_on_space_comma_colon.split(prepend_sys_path)
- )
+ sys.path[:0] = prepend_sys_path
- rvl = config.get_main_option("recursive_version_locations") == "true"
+ rvl = config.get_alembic_boolean_option("recursive_version_locations")
return ScriptDirectory(
util.coerce_resource_to_filename(script_location),
- file_template=config.get_main_option(
+ file_template=config.get_alembic_option(
"file_template", _default_file_template
),
truncate_slug_length=truncate_slug_length,
- sourceless=config.get_main_option("sourceless") == "true",
- output_encoding=config.get_main_option("output_encoding", "utf-8"),
- version_locations=version_locations,
- timezone=config.get_main_option("timezone"),
- hook_config=config.get_section("post_write_hooks", {}),
+ sourceless=config.get_alembic_boolean_option("sourceless"),
+ output_encoding=config.get_alembic_option(
+ "output_encoding", "utf-8"
+ ),
+ version_locations=config.get_version_locations_list(),
+ timezone=config.get_alembic_option("timezone"),
+ hooks=config.get_hooks_list(),
recursive_version_locations=rvl,
messaging_opts=config.messaging_opts,
)
@@ -587,23 +546,36 @@ def run_env(self) -> None:
@property
def env_py_location(self) -> str:
- return os.path.abspath(os.path.join(self.dir, "env.py"))
+ return str(Path(self.dir, "env.py"))
+
+ def _append_template(self, src: Path, dest: Path, **kw: Any) -> None:
+ with util.status(
+ f"Appending to existing {dest.absolute()}",
+ **self.messaging_opts,
+ ):
+ util.template_to_file(
+ src,
+ dest,
+ self.output_encoding,
+ append_with_newlines=True,
+ **kw,
+ )
- def _generate_template(self, src: str, dest: str, **kw: Any) -> None:
+ def _generate_template(self, src: Path, dest: Path, **kw: Any) -> None:
with util.status(
- f"Generating {os.path.abspath(dest)}", **self.messaging_opts
+ f"Generating {dest.absolute()}", **self.messaging_opts
):
util.template_to_file(src, dest, self.output_encoding, **kw)
- def _copy_file(self, src: str, dest: str) -> None:
+ def _copy_file(self, src: Path, dest: Path) -> None:
with util.status(
- f"Generating {os.path.abspath(dest)}", **self.messaging_opts
+ f"Generating {dest.absolute()}", **self.messaging_opts
):
shutil.copy(src, dest)
- def _ensure_directory(self, path: str) -> None:
- path = os.path.abspath(path)
- if not os.path.exists(path):
+ def _ensure_directory(self, path: Path) -> None:
+ path = path.absolute()
+ if not path.exists():
with util.status(
f"Creating directory {path}", **self.messaging_opts
):
@@ -628,11 +600,10 @@ def _generate_create_date(self) -> datetime.datetime:
raise util.CommandError(
"Can't locate timezone: %s" % self.timezone
) from None
- create_date = (
- datetime.datetime.utcnow()
- .replace(tzinfo=datetime.timezone.utc)
- .astimezone(tzinfo)
- )
+
+ create_date = datetime.datetime.now(
+ tz=datetime.timezone.utc
+ ).astimezone(tzinfo)
else:
create_date = datetime.datetime.now()
return create_date
@@ -644,7 +615,8 @@ def generate_revision(
head: Optional[_RevIdType] = None,
splice: Optional[bool] = False,
branch_labels: Optional[_RevIdType] = None,
- version_path: Optional[str] = None,
+ version_path: Union[str, os.PathLike[str], None] = None,
+ file_template: Optional[str] = None,
depends_on: Optional[_RevIdType] = None,
**kw: Any,
) -> Optional[Script]:
@@ -697,7 +669,7 @@ def generate_revision(
for head_ in heads:
if head_ is not None:
assert isinstance(head_, Script)
- version_path = os.path.dirname(head_.path)
+ version_path = head_._script_path.parent
break
else:
raise util.CommandError(
@@ -705,22 +677,26 @@ def generate_revision(
"please specify --version-path"
)
else:
- version_path = self.versions
+ version_path = self._singular_version_location
+ else:
+ version_path = Path(version_path)
- norm_path = os.path.normpath(os.path.abspath(version_path))
+ assert isinstance(version_path, Path)
+ norm_path = version_path.absolute()
for vers_path in self._version_locations:
- if os.path.normpath(vers_path) == norm_path:
+ if vers_path.absolute() == norm_path:
break
else:
raise util.CommandError(
- "Path %s is not represented in current "
- "version locations" % version_path
+ f"Path {version_path} is not represented in current "
+ "version locations"
)
if self.version_locations:
self._ensure_directory(version_path)
path = self._rev_path(version_path, revid, message, create_date)
+ self._ensure_directory(path.parent)
if not splice:
for head_ in heads:
@@ -749,7 +725,7 @@ def generate_revision(
resolved_depends_on = None
self._generate_template(
- os.path.join(self.dir, "script.py.mako"),
+ Path(self.dir, "script.py.mako"),
path,
up_revision=str(revid),
down_revision=revision.tuple_rev_as_scalar(
@@ -763,7 +739,7 @@ def generate_revision(
**kw,
)
- post_write_hooks = self.hook_config
+ post_write_hooks = self.hooks
if post_write_hooks:
write_hooks._run_hooks(path, post_write_hooks)
@@ -786,11 +762,11 @@ def generate_revision(
def _rev_path(
self,
- path: str,
+ path: Union[str, os.PathLike[str]],
rev_id: str,
message: Optional[str],
create_date: datetime.datetime,
- ) -> str:
+ ) -> Path:
epoch = int(create_date.timestamp())
slug = "_".join(_slug_re.findall(message or "")).lower()
if len(slug) > self.truncate_slug_length:
@@ -809,7 +785,7 @@ def _rev_path(
"second": create_date.second,
}
)
- return os.path.join(path, filename)
+ return Path(path) / filename
class Script(revision.Revision):
@@ -820,9 +796,14 @@ class Script(revision.Revision):
"""
- def __init__(self, module: ModuleType, rev_id: str, path: str):
+ def __init__(
+ self,
+ module: ModuleType,
+ rev_id: str,
+ path: Union[str, os.PathLike[str]],
+ ):
self.module = module
- self.path = path
+ self.path = _preserving_path_as_str(path)
super().__init__(
rev_id,
module.down_revision,
@@ -840,6 +821,10 @@ def __init__(self, module: ModuleType, rev_id: str, path: str):
path: str
"""Filesystem path of the script."""
+ @property
+ def _script_path(self) -> Path:
+ return Path(self.path)
+
_db_current_indicator: Optional[bool] = None
"""Utility variable which when set will cause string output to indicate
this is a "current" version in some database"""
@@ -860,7 +845,7 @@ def longdoc(self) -> str:
doc = doc.decode( # type: ignore[attr-defined]
self.module._alembic_source_encoding
)
- return doc.strip() # type: ignore[union-attr]
+ return doc.strip()
else:
return ""
@@ -972,36 +957,33 @@ def _format_down_revision(self) -> str:
return util.format_as_comma(self._versioned_down_revisions)
@classmethod
- def _from_path(
- cls, scriptdir: ScriptDirectory, path: str
- ) -> Optional[Script]:
- dir_, filename = os.path.split(path)
- return cls._from_filename(scriptdir, dir_, filename)
-
- @classmethod
- def _list_py_dir(cls, scriptdir: ScriptDirectory, path: str) -> List[str]:
+ def _list_py_dir(
+ cls, scriptdir: ScriptDirectory, path: Path
+ ) -> List[Path]:
paths = []
- for root, dirs, files in os.walk(path, topdown=True):
- if root.endswith("__pycache__"):
+ for root, dirs, files in compat.path_walk(path, top_down=True):
+ if root.name.endswith("__pycache__"):
# a special case - we may include these files
# if a `sourceless` option is specified
continue
for filename in sorted(files):
- paths.append(os.path.join(root, filename))
+ paths.append(root / filename)
if scriptdir.sourceless:
# look for __pycache__
- py_cache_path = os.path.join(root, "__pycache__")
- if os.path.exists(py_cache_path):
+ py_cache_path = root / "__pycache__"
+ if py_cache_path.exists():
# add all files from __pycache__ whose filename is not
# already in the names we got from the version directory.
# add as relative paths including __pycache__ token
- names = {filename.split(".")[0] for filename in files}
+ names = {
+ Path(filename).name.split(".")[0] for filename in files
+ }
paths.extend(
- os.path.join(py_cache_path, pyc)
- for pyc in os.listdir(py_cache_path)
- if pyc.split(".")[0] not in names
+ py_cache_path / pyc
+ for pyc in py_cache_path.iterdir()
+ if pyc.name.split(".")[0] not in names
)
if not scriptdir.recursive_version_locations:
@@ -1016,9 +998,13 @@ def _list_py_dir(cls, scriptdir: ScriptDirectory, path: str) -> List[str]:
return paths
@classmethod
- def _from_filename(
- cls, scriptdir: ScriptDirectory, dir_: str, filename: str
+ def _from_path(
+ cls, scriptdir: ScriptDirectory, path: Union[str, os.PathLike[str]]
) -> Optional[Script]:
+
+ path = Path(path)
+ dir_, filename = path.parent, path.name
+
if scriptdir.sourceless:
py_match = _sourceless_rev_file.match(filename)
else:
@@ -1036,8 +1022,8 @@ def _from_filename(
is_c = is_o = False
if is_o or is_c:
- py_exists = os.path.exists(os.path.join(dir_, py_filename))
- pyc_exists = os.path.exists(os.path.join(dir_, py_filename + "c"))
+ py_exists = (dir_ / py_filename).exists()
+ pyc_exists = (dir_ / (py_filename + "c")).exists()
# prefer .py over .pyc because we'd like to get the
# source encoding; prefer .pyc over .pyo because we'd like to
@@ -1053,14 +1039,14 @@ def _from_filename(
m = _legacy_rev.match(filename)
if not m:
raise util.CommandError(
- "Could not determine revision id from filename %s. "
+ "Could not determine revision id from "
+ f"filename {filename}. "
"Be sure the 'revision' variable is "
"declared inside the script (please see 'Upgrading "
"from Alembic 0.1 to 0.2' in the documentation)."
- % filename
)
else:
revision = m.group(1)
else:
revision = module.revision
- return Script(module, revision, os.path.join(dir_, filename))
+ return Script(module, revision, dir_ / filename)
diff --git a/libs/alembic/script/revision.py b/libs/alembic/script/revision.py
index c3108e985a..5825da34f4 100644
--- a/libs/alembic/script/revision.py
+++ b/libs/alembic/script/revision.py
@@ -45,7 +45,7 @@
_TR = TypeVar("_TR", bound=Optional[_RevisionOrStr])
_relative_destination = re.compile(r"(?:(.+?)@)?(\w+)?((?:\+|-)\d+)")
-_revision_illegal_chars = ["@", "-", "+"]
+_revision_illegal_chars = ["@", "-", "+", ":"]
class _CollectRevisionsProtocol(Protocol):
@@ -1708,7 +1708,7 @@ def tuple_rev_as_scalar(rev: None) -> None: ...
@overload
def tuple_rev_as_scalar(
- rev: Union[Tuple[_T, ...], List[_T]]
+ rev: Union[Tuple[_T, ...], List[_T]],
) -> Union[_T, Tuple[_T, ...], List[_T]]: ...
diff --git a/libs/alembic/script/write_hooks.py b/libs/alembic/script/write_hooks.py
index 9977147921..3dd49d9108 100644
--- a/libs/alembic/script/write_hooks.py
+++ b/libs/alembic/script/write_hooks.py
@@ -3,20 +3,21 @@
from __future__ import annotations
+import importlib.util
+import os
import shlex
import subprocess
import sys
from typing import Any
from typing import Callable
-from typing import Dict
-from typing import List
-from typing import Mapping
-from typing import Optional
-from typing import Union
+from typing import TYPE_CHECKING
from .. import util
from ..util import compat
+from ..util.pyfiles import _preserving_path_as_str
+if TYPE_CHECKING:
+ from ..config import PostWriteHookConfig
REVISION_SCRIPT_TOKEN = "REVISION_SCRIPT_FILENAME"
@@ -43,16 +44,19 @@ def decorate(fn):
def _invoke(
- name: str, revision: str, options: Mapping[str, Union[str, int]]
+ name: str,
+ revision_path: str | os.PathLike[str],
+ options: PostWriteHookConfig,
) -> Any:
"""Invokes the formatter registered for the given name.
:param name: The name of a formatter in the registry
- :param revision: A :class:`.MigrationRevision` instance
+ :param revision: string path to the revision file
:param options: A dict containing kwargs passed to the
specified formatter.
:raises: :class:`alembic.util.CommandError`
"""
+ revision_path = _preserving_path_as_str(revision_path)
try:
hook = _registry[name]
except KeyError as ke:
@@ -60,39 +64,31 @@ def _invoke(
f"No formatter with name '{name}' registered"
) from ke
else:
- return hook(revision, options)
+ return hook(revision_path, options)
-def _run_hooks(path: str, hook_config: Mapping[str, str]) -> None:
+def _run_hooks(
+ path: str | os.PathLike[str], hooks: list[PostWriteHookConfig]
+) -> None:
"""Invoke hooks for a generated revision."""
- from .base import _split_on_space_comma
-
- names = _split_on_space_comma.split(hook_config.get("hooks", ""))
-
- for name in names:
- if not name:
- continue
- opts = {
- key[len(name) + 1 :]: hook_config[key]
- for key in hook_config
- if key.startswith(name + ".")
- }
- opts["_hook_name"] = name
+ for hook in hooks:
+ name = hook["_hook_name"]
try:
- type_ = opts["type"]
+ type_ = hook["type"]
except KeyError as ke:
raise util.CommandError(
- f"Key {name}.type is required for post write hook {name!r}"
+ f"Key '{name}.type' (or 'type' in toml) is required "
+ f"for post write hook {name!r}"
) from ke
else:
with util.status(
f"Running post write hook {name!r}", newline=True
):
- _invoke(type_, path, opts)
+ _invoke(type_, path, hook)
-def _parse_cmdline_options(cmdline_options_str: str, path: str) -> List[str]:
+def _parse_cmdline_options(cmdline_options_str: str, path: str) -> list[str]:
"""Parse options from a string into a list.
Also substitutes the revision script token with the actual filename of
@@ -113,17 +109,38 @@ def _parse_cmdline_options(cmdline_options_str: str, path: str) -> List[str]:
return cmdline_options_list
-@register("console_scripts")
-def console_scripts(
- path: str, options: dict, ignore_output: bool = False
-) -> None:
+def _get_required_option(options: dict, name: str) -> str:
try:
- entrypoint_name = options["entrypoint"]
+ return options[name]
except KeyError as ke:
raise util.CommandError(
- f"Key {options['_hook_name']}.entrypoint is required for post "
+ f"Key {options['_hook_name']}.{name} is required for post "
f"write hook {options['_hook_name']!r}"
) from ke
+
+
+def _run_hook(
+ path: str, options: dict, ignore_output: bool, command: list[str]
+) -> None:
+ cwd: str | None = options.get("cwd", None)
+ cmdline_options_str = options.get("options", "")
+ cmdline_options_list = _parse_cmdline_options(cmdline_options_str, path)
+
+ kw: dict[str, Any] = {}
+ if ignore_output:
+ kw["stdout"] = kw["stderr"] = subprocess.DEVNULL
+
+ subprocess.run([*command, *cmdline_options_list], cwd=cwd, **kw)
+
+
+@register("console_scripts")
+def console_scripts(
+ path: str,
+ options: dict,
+ ignore_output: bool = False,
+ verify_version: tuple[int, ...] | None = None,
+) -> None:
+ entrypoint_name = _get_required_option(options, "entrypoint")
for entry in compat.importlib_metadata_get("console_scripts"):
if entry.name == entrypoint_name:
impl: Any = entry
@@ -132,48 +149,33 @@ def console_scripts(
raise util.CommandError(
f"Could not find entrypoint console_scripts.{entrypoint_name}"
)
- cwd: Optional[str] = options.get("cwd", None)
- cmdline_options_str = options.get("options", "")
- cmdline_options_list = _parse_cmdline_options(cmdline_options_str, path)
- kw: Dict[str, Any] = {}
- if ignore_output:
- kw["stdout"] = kw["stderr"] = subprocess.DEVNULL
+ if verify_version:
+ pyscript = (
+ f"import {impl.module}; "
+ f"assert tuple(int(x) for x in {impl.module}.__version__.split('.')) >= {verify_version}, " # noqa: E501
+ f"'need exactly version {verify_version} of {impl.name}'; "
+ f"{impl.module}.{impl.attr}()"
+ )
+ else:
+ pyscript = f"import {impl.module}; {impl.module}.{impl.attr}()"
- subprocess.run(
- [
- sys.executable,
- "-c",
- f"import {impl.module}; {impl.module}.{impl.attr}()",
- ]
- + cmdline_options_list,
- cwd=cwd,
- **kw,
- )
+ command = [sys.executable, "-c", pyscript]
+ _run_hook(path, options, ignore_output, command)
@register("exec")
def exec_(path: str, options: dict, ignore_output: bool = False) -> None:
- try:
- executable = options["executable"]
- except KeyError as ke:
- raise util.CommandError(
- f"Key {options['_hook_name']}.executable is required for post "
- f"write hook {options['_hook_name']!r}"
- ) from ke
- cwd: Optional[str] = options.get("cwd", None)
- cmdline_options_str = options.get("options", "")
- cmdline_options_list = _parse_cmdline_options(cmdline_options_str, path)
+ executable = _get_required_option(options, "executable")
+ _run_hook(path, options, ignore_output, command=[executable])
- kw: Dict[str, Any] = {}
- if ignore_output:
- kw["stdout"] = kw["stderr"] = subprocess.DEVNULL
- subprocess.run(
- [
- executable,
- *cmdline_options_list,
- ],
- cwd=cwd,
- **kw,
- )
+@register("module")
+def module(path: str, options: dict, ignore_output: bool = False) -> None:
+ module_name = _get_required_option(options, "module")
+
+ if importlib.util.find_spec(module_name) is None:
+ raise util.CommandError(f"Could not find module {module_name}")
+
+ command = [sys.executable, "-m", module_name]
+ _run_hook(path, options, ignore_output, command)
diff --git a/libs/alembic/templates/async/alembic.ini.mako b/libs/alembic/templates/async/alembic.ini.mako
index 7eee913205..02ccb0f6de 100644
--- a/libs/alembic/templates/async/alembic.ini.mako
+++ b/libs/alembic/templates/async/alembic.ini.mako
@@ -2,21 +2,28 @@
[alembic]
# path to migration scripts.
-# Use forward slashes (/) also on windows to provide an os agnostic path
+# this is typically a path given in POSIX (e.g. forward slashes)
+# format, relative to the token %(here)s which refers to the location of this
+# ini file
script_location = ${script_location}
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
+# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
+# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
+# Or organize into date-based subdirectories (requires recursive_version_locations = true)
+# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
-# defaults to the current working directory.
+# defaults to the current working directory. for multiple paths, the path separator
+# is defined by "path_separator" below.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
-# If specified, requires the python>=3.9 or backports.zoneinfo library.
-# Any required deps can installed by adding `alembic[tz]` to the pip requirements
+# If specified, requires the tzdata library which can be installed by adding
+# `alembic[tz]` to the pip requirements.
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
@@ -34,21 +41,38 @@ prepend_sys_path = .
# sourceless = false
# version location specification; This defaults
-# to ${script_location}/versions. When using multiple version
+# to /versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
-# The path separator used here should be the separator specified by "version_path_separator" below.
-# version_locations = %(here)s/bar:%(here)s/bat:${script_location}/versions
-
-# version path separator; As mentioned above, this is the character used to split
-# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
-# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
-# Valid values for version_path_separator are:
+# The path separator used here should be the separator specified by "path_separator"
+# below.
+# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
+
+# path_separator; This indicates what character is used to split lists of file
+# paths, including version_locations and prepend_sys_path within configparser
+# files such as alembic.ini.
+# The default rendered in new alembic.ini files is "os", which uses os.pathsep
+# to provide os-dependent path splitting.
+#
+# Note that in order to support legacy alembic.ini files, this default does NOT
+# take place if path_separator is not present in alembic.ini. If this
+# option is omitted entirely, fallback logic is as follows:
+#
+# 1. Parsing of the version_locations option falls back to using the legacy
+# "version_path_separator" key, which if absent then falls back to the legacy
+# behavior of splitting on spaces and/or commas.
+# 2. Parsing of the prepend_sys_path option falls back to the legacy
+# behavior of splitting on spaces, commas, or colons.
+#
+# Valid values for path_separator are:
#
-# version_path_separator = :
-# version_path_separator = ;
-# version_path_separator = space
-# version_path_separator = newline
-version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
+# path_separator = :
+# path_separator = ;
+# path_separator = space
+# path_separator = newline
+#
+# Use os.pathsep. Default configuration used for new projects.
+path_separator = os
+
# set to 'true' to search source files recursively
# in each "version_locations" directory
@@ -59,6 +83,9 @@ version_path_separator = os # Use os.pathsep. Default configuration used for ne
# are written from script.py.mako
# output_encoding = utf-8
+# database URL. This is consumed by the user-maintained env.py script only.
+# other means of configuring database URLs may be customized within the env.py
+# file.
sqlalchemy.url = driver://user:pass@localhost/dbname
@@ -73,13 +100,20 @@ sqlalchemy.url = driver://user:pass@localhost/dbname
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
-# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
+# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
+# hooks = ruff
+# ruff.type = module
+# ruff.module = ruff
+# ruff.options = check --fix REVISION_SCRIPT_FILENAME
+
+# Alternatively, use the exec runner to execute a binary found on your PATH
# hooks = ruff
# ruff.type = exec
-# ruff.executable = %(here)s/.venv/bin/ruff
-# ruff.options = --fix REVISION_SCRIPT_FILENAME
+# ruff.executable = ruff
+# ruff.options = check --fix REVISION_SCRIPT_FILENAME
-# Logging configuration
+# Logging configuration. This is also consumed by the user-maintained
+# env.py script only.
[loggers]
keys = root,sqlalchemy,alembic
diff --git a/libs/alembic/templates/async/script.py.mako b/libs/alembic/templates/async/script.py.mako
index fbc4b07dce..11016301e7 100644
--- a/libs/alembic/templates/async/script.py.mako
+++ b/libs/alembic/templates/async/script.py.mako
@@ -13,14 +13,16 @@ ${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
-down_revision: Union[str, None] = ${repr(down_revision)}
+down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
+ """Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
+ """Downgrade schema."""
${downgrades if downgrades else "pass"}
diff --git a/libs/alembic/templates/generic/alembic.ini.mako b/libs/alembic/templates/generic/alembic.ini.mako
index f1f76cae80..0127b2af8d 100644
--- a/libs/alembic/templates/generic/alembic.ini.mako
+++ b/libs/alembic/templates/generic/alembic.ini.mako
@@ -1,8 +1,10 @@
# A generic, single database configuration.
[alembic]
-# path to migration scripts
-# Use forward slashes (/) also on windows to provide an os agnostic path
+# path to migration scripts.
+# this is typically a path given in POSIX (e.g. forward slashes)
+# format, relative to the token %(here)s which refers to the location of this
+# ini file
script_location = ${script_location}
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
@@ -10,15 +12,19 @@ script_location = ${script_location}
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
+# Or organize into date-based subdirectories (requires recursive_version_locations = true)
+# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
-# defaults to the current working directory.
+# defaults to the current working directory. for multiple paths, the path separator
+# is defined by "path_separator" below.
prepend_sys_path = .
+
# timezone to use when rendering the date within the migration file
# as well as the filename.
-# If specified, requires the python>=3.9 or backports.zoneinfo library.
-# Any required deps can installed by adding `alembic[tz]` to the pip requirements
+# If specified, requires the tzdata library which can be installed by adding
+# `alembic[tz]` to the pip requirements.
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
@@ -36,21 +42,37 @@ prepend_sys_path = .
# sourceless = false
# version location specification; This defaults
-# to ${script_location}/versions. When using multiple version
+# to /versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
-# The path separator used here should be the separator specified by "version_path_separator" below.
-# version_locations = %(here)s/bar:%(here)s/bat:${script_location}/versions
-
-# version path separator; As mentioned above, this is the character used to split
-# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
-# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
-# Valid values for version_path_separator are:
+# The path separator used here should be the separator specified by "path_separator"
+# below.
+# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
+
+# path_separator; This indicates what character is used to split lists of file
+# paths, including version_locations and prepend_sys_path within configparser
+# files such as alembic.ini.
+# The default rendered in new alembic.ini files is "os", which uses os.pathsep
+# to provide os-dependent path splitting.
+#
+# Note that in order to support legacy alembic.ini files, this default does NOT
+# take place if path_separator is not present in alembic.ini. If this
+# option is omitted entirely, fallback logic is as follows:
+#
+# 1. Parsing of the version_locations option falls back to using the legacy
+# "version_path_separator" key, which if absent then falls back to the legacy
+# behavior of splitting on spaces and/or commas.
+# 2. Parsing of the prepend_sys_path option falls back to the legacy
+# behavior of splitting on spaces, commas, or colons.
#
-# version_path_separator = :
-# version_path_separator = ;
-# version_path_separator = space
-# version_path_separator = newline
-version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
+# Valid values for path_separator are:
+#
+# path_separator = :
+# path_separator = ;
+# path_separator = space
+# path_separator = newline
+#
+# Use os.pathsep. Default configuration used for new projects.
+path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
@@ -61,6 +83,9 @@ version_path_separator = os # Use os.pathsep. Default configuration used for ne
# are written from script.py.mako
# output_encoding = utf-8
+# database URL. This is consumed by the user-maintained env.py script only.
+# other means of configuring database URLs may be customized within the env.py
+# file.
sqlalchemy.url = driver://user:pass@localhost/dbname
@@ -75,13 +100,20 @@ sqlalchemy.url = driver://user:pass@localhost/dbname
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
-# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
+# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
+# hooks = ruff
+# ruff.type = module
+# ruff.module = ruff
+# ruff.options = check --fix REVISION_SCRIPT_FILENAME
+
+# Alternatively, use the exec runner to execute a binary found on your PATH
# hooks = ruff
# ruff.type = exec
-# ruff.executable = %(here)s/.venv/bin/ruff
-# ruff.options = --fix REVISION_SCRIPT_FILENAME
+# ruff.executable = ruff
+# ruff.options = check --fix REVISION_SCRIPT_FILENAME
-# Logging configuration
+# Logging configuration. This is also consumed by the user-maintained
+# env.py script only.
[loggers]
keys = root,sqlalchemy,alembic
diff --git a/libs/alembic/templates/generic/script.py.mako b/libs/alembic/templates/generic/script.py.mako
index fbc4b07dce..11016301e7 100644
--- a/libs/alembic/templates/generic/script.py.mako
+++ b/libs/alembic/templates/generic/script.py.mako
@@ -13,14 +13,16 @@ ${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
-down_revision: Union[str, None] = ${repr(down_revision)}
+down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
+ """Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
+ """Downgrade schema."""
${downgrades if downgrades else "pass"}
diff --git a/libs/alembic/templates/multidb/alembic.ini.mako b/libs/alembic/templates/multidb/alembic.ini.mako
index bf383ea1de..76846465be 100644
--- a/libs/alembic/templates/multidb/alembic.ini.mako
+++ b/libs/alembic/templates/multidb/alembic.ini.mako
@@ -1,8 +1,10 @@
# a multi-database configuration.
[alembic]
-# path to migration scripts
-# Use forward slashes (/) also on windows to provide an os agnostic path
+# path to migration scripts.
+# this is typically a path given in POSIX (e.g. forward slashes)
+# format, relative to the token %(here)s which refers to the location of this
+# ini file
script_location = ${script_location}
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
@@ -10,15 +12,18 @@ script_location = ${script_location}
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
+# Or organize into date-based subdirectories (requires recursive_version_locations = true)
+# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
-# defaults to the current working directory.
+# defaults to the current working directory. for multiple paths, the path separator
+# is defined by "path_separator" below.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
-# If specified, requires the python>=3.9 or backports.zoneinfo library.
-# Any required deps can installed by adding `alembic[tz]` to the pip requirements
+# If specified, requires the tzdata library which can be installed by adding
+# `alembic[tz]` to the pip requirements.
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
@@ -36,21 +41,37 @@ prepend_sys_path = .
# sourceless = false
# version location specification; This defaults
-# to ${script_location}/versions. When using multiple version
+# to /versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
-# The path separator used here should be the separator specified by "version_path_separator" below.
-# version_locations = %(here)s/bar:%(here)s/bat:${script_location}/versions
-
-# version path separator; As mentioned above, this is the character used to split
-# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
-# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
-# Valid values for version_path_separator are:
+# The path separator used here should be the separator specified by "path_separator"
+# below.
+# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
+
+# path_separator; This indicates what character is used to split lists of file
+# paths, including version_locations and prepend_sys_path within configparser
+# files such as alembic.ini.
+# The default rendered in new alembic.ini files is "os", which uses os.pathsep
+# to provide os-dependent path splitting.
+#
+# Note that in order to support legacy alembic.ini files, this default does NOT
+# take place if path_separator is not present in alembic.ini. If this
+# option is omitted entirely, fallback logic is as follows:
+#
+# 1. Parsing of the version_locations option falls back to using the legacy
+# "version_path_separator" key, which if absent then falls back to the legacy
+# behavior of splitting on spaces and/or commas.
+# 2. Parsing of the prepend_sys_path option falls back to the legacy
+# behavior of splitting on spaces, commas, or colons.
+#
+# Valid values for path_separator are:
#
-# version_path_separator = :
-# version_path_separator = ;
-# version_path_separator = space
-# version_path_separator = newline
-version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
+# path_separator = :
+# path_separator = ;
+# path_separator = space
+# path_separator = newline
+#
+# Use os.pathsep. Default configuration used for new projects.
+path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
@@ -61,6 +82,13 @@ version_path_separator = os # Use os.pathsep. Default configuration used for ne
# are written from script.py.mako
# output_encoding = utf-8
+# for multiple database configuration, new named sections are added
+# which each include a distinct ``sqlalchemy.url`` entry. A custom value
+# ``databases`` is added which indicates a listing of the per-database sections.
+# The ``databases`` entry as well as the URLs present in the ``[engine1]``
+# and ``[engine2]`` sections continue to be consumed by the user-maintained env.py
+# script only.
+
databases = engine1, engine2
[engine1]
@@ -80,13 +108,20 @@ sqlalchemy.url = driver://user:pass@localhost/dbname2
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
-# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
+# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
+# hooks = ruff
+# ruff.type = module
+# ruff.module = ruff
+# ruff.options = check --fix REVISION_SCRIPT_FILENAME
+
+# Alternatively, use the exec runner to execute a binary found on your PATH
# hooks = ruff
# ruff.type = exec
-# ruff.executable = %(here)s/.venv/bin/ruff
-# ruff.options = --fix REVISION_SCRIPT_FILENAME
+# ruff.executable = ruff
+# ruff.options = check --fix REVISION_SCRIPT_FILENAME
-# Logging configuration
+# Logging configuration. This is also consumed by the user-maintained
+# env.py script only.
[loggers]
keys = root,sqlalchemy,alembic
diff --git a/libs/alembic/templates/multidb/script.py.mako b/libs/alembic/templates/multidb/script.py.mako
index 6108b8a0dc..8e667d84c8 100644
--- a/libs/alembic/templates/multidb/script.py.mako
+++ b/libs/alembic/templates/multidb/script.py.mako
@@ -16,16 +16,18 @@ ${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
-down_revision: Union[str, None] = ${repr(down_revision)}
+down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade(engine_name: str) -> None:
+ """Upgrade schema."""
globals()["upgrade_%s" % engine_name]()
def downgrade(engine_name: str) -> None:
+ """Downgrade schema."""
globals()["downgrade_%s" % engine_name]()
<%
@@ -38,10 +40,12 @@ def downgrade(engine_name: str) -> None:
% for db_name in re.split(r',\s*', db_names):
def upgrade_${db_name}() -> None:
+ """Upgrade ${db_name} schema."""
${context.get("%s_upgrades" % db_name, "pass")}
def downgrade_${db_name}() -> None:
+ """Downgrade ${db_name} schema."""
${context.get("%s_downgrades" % db_name, "pass")}
% endfor
diff --git a/libs/alembic/templates/pyproject/README b/libs/alembic/templates/pyproject/README
new file mode 100644
index 0000000000..fdacc05f68
--- /dev/null
+++ b/libs/alembic/templates/pyproject/README
@@ -0,0 +1 @@
+pyproject configuration, based on the generic configuration.
\ No newline at end of file
diff --git a/libs/alembic/templates/pyproject/alembic.ini.mako b/libs/alembic/templates/pyproject/alembic.ini.mako
new file mode 100644
index 0000000000..3d10f0e46c
--- /dev/null
+++ b/libs/alembic/templates/pyproject/alembic.ini.mako
@@ -0,0 +1,44 @@
+# A generic, single database configuration.
+
+[alembic]
+
+# database URL. This is consumed by the user-maintained env.py script only.
+# other means of configuring database URLs may be customized within the env.py
+# file.
+sqlalchemy.url = driver://user:pass@localhost/dbname
+
+
+# Logging configuration
+[loggers]
+keys = root,sqlalchemy,alembic
+
+[handlers]
+keys = console
+
+[formatters]
+keys = generic
+
+[logger_root]
+level = WARNING
+handlers = console
+qualname =
+
+[logger_sqlalchemy]
+level = WARNING
+handlers =
+qualname = sqlalchemy.engine
+
+[logger_alembic]
+level = INFO
+handlers =
+qualname = alembic
+
+[handler_console]
+class = StreamHandler
+args = (sys.stderr,)
+level = NOTSET
+formatter = generic
+
+[formatter_generic]
+format = %(levelname)-5.5s [%(name)s] %(message)s
+datefmt = %H:%M:%S
diff --git a/libs/alembic/templates/pyproject/env.py b/libs/alembic/templates/pyproject/env.py
new file mode 100644
index 0000000000..36112a3c68
--- /dev/null
+++ b/libs/alembic/templates/pyproject/env.py
@@ -0,0 +1,78 @@
+from logging.config import fileConfig
+
+from sqlalchemy import engine_from_config
+from sqlalchemy import pool
+
+from alembic import context
+
+# this is the Alembic Config object, which provides
+# access to the values within the .ini file in use.
+config = context.config
+
+# Interpret the config file for Python logging.
+# This line sets up loggers basically.
+if config.config_file_name is not None:
+ fileConfig(config.config_file_name)
+
+# add your model's MetaData object here
+# for 'autogenerate' support
+# from myapp import mymodel
+# target_metadata = mymodel.Base.metadata
+target_metadata = None
+
+# other values from the config, defined by the needs of env.py,
+# can be acquired:
+# my_important_option = config.get_main_option("my_important_option")
+# ... etc.
+
+
+def run_migrations_offline() -> None:
+ """Run migrations in 'offline' mode.
+
+ This configures the context with just a URL
+ and not an Engine, though an Engine is acceptable
+ here as well. By skipping the Engine creation
+ we don't even need a DBAPI to be available.
+
+ Calls to context.execute() here emit the given string to the
+ script output.
+
+ """
+ url = config.get_main_option("sqlalchemy.url")
+ context.configure(
+ url=url,
+ target_metadata=target_metadata,
+ literal_binds=True,
+ dialect_opts={"paramstyle": "named"},
+ )
+
+ with context.begin_transaction():
+ context.run_migrations()
+
+
+def run_migrations_online() -> None:
+ """Run migrations in 'online' mode.
+
+ In this scenario we need to create an Engine
+ and associate a connection with the context.
+
+ """
+ connectable = engine_from_config(
+ config.get_section(config.config_ini_section, {}),
+ prefix="sqlalchemy.",
+ poolclass=pool.NullPool,
+ )
+
+ with connectable.connect() as connection:
+ context.configure(
+ connection=connection, target_metadata=target_metadata
+ )
+
+ with context.begin_transaction():
+ context.run_migrations()
+
+
+if context.is_offline_mode():
+ run_migrations_offline()
+else:
+ run_migrations_online()
diff --git a/libs/alembic/templates/pyproject/pyproject.toml.mako b/libs/alembic/templates/pyproject/pyproject.toml.mako
new file mode 100644
index 0000000000..7edd43b0c9
--- /dev/null
+++ b/libs/alembic/templates/pyproject/pyproject.toml.mako
@@ -0,0 +1,84 @@
+[tool.alembic]
+
+# path to migration scripts.
+# this is typically a path given in POSIX (e.g. forward slashes)
+# format, relative to the token %(here)s which refers to the location of this
+# ini file
+script_location = "${script_location}"
+
+# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
+# Uncomment the line below if you want the files to be prepended with date and time
+# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
+# for all available tokens
+# file_template = "%%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s"
+# Or organize into date-based subdirectories (requires recursive_version_locations = true)
+# file_template = "%%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s"
+
+# additional paths to be prepended to sys.path. defaults to the current working directory.
+prepend_sys_path = [
+ "."
+]
+
+# timezone to use when rendering the date within the migration file
+# as well as the filename.
+# If specified, requires the tzdata library which can be installed by adding
+# `alembic[tz]` to the pip requirements.
+# string value is passed to ZoneInfo()
+# leave blank for localtime
+# timezone =
+
+# max length of characters to apply to the "slug" field
+# truncate_slug_length = 40
+
+# set to 'true' to run the environment during
+# the 'revision' command, regardless of autogenerate
+# revision_environment = false
+
+# set to 'true' to allow .pyc and .pyo files without
+# a source .py file to be detected as revisions in the
+# versions/ directory
+# sourceless = false
+
+# version location specification; This defaults
+# to /versions. When using multiple version
+# directories, initial revisions must be specified with --version-path.
+# version_locations = [
+# "%(here)s/alembic/versions",
+# "%(here)s/foo/bar"
+# ]
+
+
+# set to 'true' to search source files recursively
+# in each "version_locations" directory
+# new in Alembic version 1.10
+# recursive_version_locations = false
+
+# the output encoding used when revision files
+# are written from script.py.mako
+# output_encoding = "utf-8"
+
+# This section defines scripts or Python functions that are run
+# on newly generated revision scripts. See the documentation for further
+# detail and examples
+# [[tool.alembic.post_write_hooks]]
+# format using "black" - use the console_scripts runner,
+# against the "black" entrypoint
+# name = "black"
+# type = "console_scripts"
+# entrypoint = "black"
+# options = "-l 79 REVISION_SCRIPT_FILENAME"
+#
+# [[tool.alembic.post_write_hooks]]
+# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
+# name = "ruff"
+# type = "module"
+# module = "ruff"
+# options = "check --fix REVISION_SCRIPT_FILENAME"
+#
+# [[tool.alembic.post_write_hooks]]
+# Alternatively, use the exec runner to execute a binary found on your PATH
+# name = "ruff"
+# type = "exec"
+# executable = "ruff"
+# options = "check --fix REVISION_SCRIPT_FILENAME"
+
diff --git a/libs/alembic/templates/pyproject/script.py.mako b/libs/alembic/templates/pyproject/script.py.mako
new file mode 100644
index 0000000000..11016301e7
--- /dev/null
+++ b/libs/alembic/templates/pyproject/script.py.mako
@@ -0,0 +1,28 @@
+"""${message}
+
+Revision ID: ${up_revision}
+Revises: ${down_revision | comma,n}
+Create Date: ${create_date}
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+${imports if imports else ""}
+
+# revision identifiers, used by Alembic.
+revision: str = ${repr(up_revision)}
+down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
+branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
+depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
+
+
+def upgrade() -> None:
+ """Upgrade schema."""
+ ${upgrades if upgrades else "pass"}
+
+
+def downgrade() -> None:
+ """Downgrade schema."""
+ ${downgrades if downgrades else "pass"}
diff --git a/libs/alembic/templates/pyproject_async/README b/libs/alembic/templates/pyproject_async/README
new file mode 100644
index 0000000000..dfd718d3b9
--- /dev/null
+++ b/libs/alembic/templates/pyproject_async/README
@@ -0,0 +1 @@
+pyproject configuration, with an async dbapi.
\ No newline at end of file
diff --git a/libs/alembic/templates/pyproject_async/alembic.ini.mako b/libs/alembic/templates/pyproject_async/alembic.ini.mako
new file mode 100644
index 0000000000..3d10f0e46c
--- /dev/null
+++ b/libs/alembic/templates/pyproject_async/alembic.ini.mako
@@ -0,0 +1,44 @@
+# A generic, single database configuration.
+
+[alembic]
+
+# database URL. This is consumed by the user-maintained env.py script only.
+# other means of configuring database URLs may be customized within the env.py
+# file.
+sqlalchemy.url = driver://user:pass@localhost/dbname
+
+
+# Logging configuration
+[loggers]
+keys = root,sqlalchemy,alembic
+
+[handlers]
+keys = console
+
+[formatters]
+keys = generic
+
+[logger_root]
+level = WARNING
+handlers = console
+qualname =
+
+[logger_sqlalchemy]
+level = WARNING
+handlers =
+qualname = sqlalchemy.engine
+
+[logger_alembic]
+level = INFO
+handlers =
+qualname = alembic
+
+[handler_console]
+class = StreamHandler
+args = (sys.stderr,)
+level = NOTSET
+formatter = generic
+
+[formatter_generic]
+format = %(levelname)-5.5s [%(name)s] %(message)s
+datefmt = %H:%M:%S
diff --git a/libs/alembic/templates/pyproject_async/env.py b/libs/alembic/templates/pyproject_async/env.py
new file mode 100644
index 0000000000..9f2d519400
--- /dev/null
+++ b/libs/alembic/templates/pyproject_async/env.py
@@ -0,0 +1,89 @@
+import asyncio
+from logging.config import fileConfig
+
+from sqlalchemy import pool
+from sqlalchemy.engine import Connection
+from sqlalchemy.ext.asyncio import async_engine_from_config
+
+from alembic import context
+
+# this is the Alembic Config object, which provides
+# access to the values within the .ini file in use.
+config = context.config
+
+# Interpret the config file for Python logging.
+# This line sets up loggers basically.
+if config.config_file_name is not None:
+ fileConfig(config.config_file_name)
+
+# add your model's MetaData object here
+# for 'autogenerate' support
+# from myapp import mymodel
+# target_metadata = mymodel.Base.metadata
+target_metadata = None
+
+# other values from the config, defined by the needs of env.py,
+# can be acquired:
+# my_important_option = config.get_main_option("my_important_option")
+# ... etc.
+
+
+def run_migrations_offline() -> None:
+ """Run migrations in 'offline' mode.
+
+ This configures the context with just a URL
+ and not an Engine, though an Engine is acceptable
+ here as well. By skipping the Engine creation
+ we don't even need a DBAPI to be available.
+
+ Calls to context.execute() here emit the given string to the
+ script output.
+
+ """
+ url = config.get_main_option("sqlalchemy.url")
+ context.configure(
+ url=url,
+ target_metadata=target_metadata,
+ literal_binds=True,
+ dialect_opts={"paramstyle": "named"},
+ )
+
+ with context.begin_transaction():
+ context.run_migrations()
+
+
+def do_run_migrations(connection: Connection) -> None:
+ context.configure(connection=connection, target_metadata=target_metadata)
+
+ with context.begin_transaction():
+ context.run_migrations()
+
+
+async def run_async_migrations() -> None:
+ """In this scenario we need to create an Engine
+ and associate a connection with the context.
+
+ """
+
+ connectable = async_engine_from_config(
+ config.get_section(config.config_ini_section, {}),
+ prefix="sqlalchemy.",
+ poolclass=pool.NullPool,
+ )
+
+ async with connectable.connect() as connection:
+ await connection.run_sync(do_run_migrations)
+
+ await connectable.dispose()
+
+
+def run_migrations_online() -> None:
+ """Run migrations in 'online' mode."""
+
+ asyncio.run(run_async_migrations())
+
+
+if context.is_offline_mode():
+ run_migrations_offline()
+else:
+ run_migrations_online()
diff --git a/libs/alembic/templates/pyproject_async/pyproject.toml.mako b/libs/alembic/templates/pyproject_async/pyproject.toml.mako
new file mode 100644
index 0000000000..7edd43b0c9
--- /dev/null
+++ b/libs/alembic/templates/pyproject_async/pyproject.toml.mako
@@ -0,0 +1,84 @@
+[tool.alembic]
+
+# path to migration scripts.
+# this is typically a path given in POSIX (e.g. forward slashes)
+# format, relative to the token %(here)s which refers to the location of this
+# ini file
+script_location = "${script_location}"
+
+# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
+# Uncomment the line below if you want the files to be prepended with date and time
+# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
+# for all available tokens
+# file_template = "%%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s"
+# Or organize into date-based subdirectories (requires recursive_version_locations = true)
+# file_template = "%%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s"
+
+# additional paths to be prepended to sys.path. defaults to the current working directory.
+prepend_sys_path = [
+ "."
+]
+
+# timezone to use when rendering the date within the migration file
+# as well as the filename.
+# If specified, requires the tzdata library which can be installed by adding
+# `alembic[tz]` to the pip requirements.
+# string value is passed to ZoneInfo()
+# leave blank for localtime
+# timezone =
+
+# max length of characters to apply to the "slug" field
+# truncate_slug_length = 40
+
+# set to 'true' to run the environment during
+# the 'revision' command, regardless of autogenerate
+# revision_environment = false
+
+# set to 'true' to allow .pyc and .pyo files without
+# a source .py file to be detected as revisions in the
+# versions/ directory
+# sourceless = false
+
+# version location specification; This defaults
+# to /versions. When using multiple version
+# directories, initial revisions must be specified with --version-path.
+# version_locations = [
+# "%(here)s/alembic/versions",
+# "%(here)s/foo/bar"
+# ]
+
+
+# set to 'true' to search source files recursively
+# in each "version_locations" directory
+# new in Alembic version 1.10
+# recursive_version_locations = false
+
+# the output encoding used when revision files
+# are written from script.py.mako
+# output_encoding = "utf-8"
+
+# This section defines scripts or Python functions that are run
+# on newly generated revision scripts. See the documentation for further
+# detail and examples
+# [[tool.alembic.post_write_hooks]]
+# format using "black" - use the console_scripts runner,
+# against the "black" entrypoint
+# name = "black"
+# type = "console_scripts"
+# entrypoint = "black"
+# options = "-l 79 REVISION_SCRIPT_FILENAME"
+#
+# [[tool.alembic.post_write_hooks]]
+# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
+# name = "ruff"
+# type = "module"
+# module = "ruff"
+# options = "check --fix REVISION_SCRIPT_FILENAME"
+#
+# [[tool.alembic.post_write_hooks]]
+# Alternatively, use the exec runner to execute a binary found on your PATH
+# name = "ruff"
+# type = "exec"
+# executable = "ruff"
+# options = "check --fix REVISION_SCRIPT_FILENAME"
+
diff --git a/libs/alembic/templates/pyproject_async/script.py.mako b/libs/alembic/templates/pyproject_async/script.py.mako
new file mode 100644
index 0000000000..11016301e7
--- /dev/null
+++ b/libs/alembic/templates/pyproject_async/script.py.mako
@@ -0,0 +1,28 @@
+"""${message}
+
+Revision ID: ${up_revision}
+Revises: ${down_revision | comma,n}
+Create Date: ${create_date}
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+${imports if imports else ""}
+
+# revision identifiers, used by Alembic.
+revision: str = ${repr(up_revision)}
+down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
+branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
+depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
+
+
+def upgrade() -> None:
+ """Upgrade schema."""
+ ${upgrades if upgrades else "pass"}
+
+
+def downgrade() -> None:
+ """Downgrade schema."""
+ ${downgrades if downgrades else "pass"}
diff --git a/libs/alembic/testing/__init__.py b/libs/alembic/testing/__init__.py
index 0407adfe9c..32915081d9 100644
--- a/libs/alembic/testing/__init__.py
+++ b/libs/alembic/testing/__init__.py
@@ -9,12 +9,15 @@
from sqlalchemy.testing.config import combinations
from sqlalchemy.testing.config import fixture
from sqlalchemy.testing.config import requirements as requires
+from sqlalchemy.testing.config import Variation
+from sqlalchemy.testing.config import variation
from .assertions import assert_raises
from .assertions import assert_raises_message
from .assertions import emits_python_deprecation_warning
from .assertions import eq_
from .assertions import eq_ignore_whitespace
+from .assertions import expect_deprecated
from .assertions import expect_raises
from .assertions import expect_raises_message
from .assertions import expect_sqlalchemy_deprecated
diff --git a/libs/alembic/testing/assertions.py b/libs/alembic/testing/assertions.py
index e071697cd7..e76103d5f3 100644
--- a/libs/alembic/testing/assertions.py
+++ b/libs/alembic/testing/assertions.py
@@ -8,6 +8,7 @@
from sqlalchemy import exc as sa_exc
from sqlalchemy.engine import default
+from sqlalchemy.engine import URL
from sqlalchemy.testing.assertions import _expect_warnings
from sqlalchemy.testing.assertions import eq_ # noqa
from sqlalchemy.testing.assertions import is_ # noqa
@@ -17,8 +18,6 @@
from sqlalchemy.testing.assertions import ne_ # noqa
from sqlalchemy.util import decorator
-from ..util import sqla_compat
-
def _assert_proper_exception_context(exception):
"""assert that any exception we're catching does not have a __context__
@@ -127,12 +126,13 @@ def _get_dialect(name):
if name is None or name == "default":
return default.DefaultDialect()
else:
- d = sqla_compat._create_url(name).get_dialect()()
+ d = URL.create(name).get_dialect()()
if name == "postgresql":
d.implicit_returning = True
elif name == "mssql":
d.legacy_schema_aliasing = False
+ d.default_schema_name = "dbo"
return d
@@ -168,6 +168,10 @@ def decorate(fn, *args, **kw):
return decorate
+def expect_deprecated(*messages, **kw):
+ return _expect_warnings(DeprecationWarning, messages, **kw)
+
+
def expect_sqlalchemy_deprecated(*messages, **kw):
return _expect_warnings(sa_exc.SADeprecationWarning, messages, **kw)
diff --git a/libs/alembic/testing/env.py b/libs/alembic/testing/env.py
index c37b4d3032..ad4de78353 100644
--- a/libs/alembic/testing/env.py
+++ b/libs/alembic/testing/env.py
@@ -1,5 +1,7 @@
import importlib.machinery
+import logging
import os
+from pathlib import Path
import shutil
import textwrap
@@ -16,15 +18,37 @@
def _get_staging_directory():
if provision.FOLLOWER_IDENT:
- return "scratch_%s" % provision.FOLLOWER_IDENT
+ return f"scratch_{provision.FOLLOWER_IDENT}"
else:
return "scratch"
+_restore_log = None
+
+
+def _replace_logger():
+ global _restore_log
+ if _restore_log is None:
+ _restore_log = (logging.root, logging.Logger.manager)
+ logging.root = logging.RootLogger(logging.WARNING)
+ logging.Logger.root = logging.root
+ logging.Logger.manager = logging.Manager(logging.root)
+
+
+def _restore_logger():
+ global _restore_log
+
+ if _restore_log is not None:
+ logging.root, logging.Logger.manager = _restore_log
+ logging.Logger.root = logging.root
+ _restore_log = None
+
+
def staging_env(create=True, template="generic", sourceless=False):
+ _replace_logger()
cfg = _testing_config()
if create:
- path = os.path.join(_get_staging_directory(), "scripts")
+ path = _join_path(_get_staging_directory(), "scripts")
assert not os.path.exists(path), (
"staging directory %s already exists; poor cleanup?" % path
)
@@ -47,7 +71,7 @@ def staging_env(create=True, template="generic", sourceless=False):
"pep3147_everything",
), sourceless
make_sourceless(
- os.path.join(path, "env.py"),
+ _join_path(path, "env.py"),
"pep3147" if "pep3147" in sourceless else "simple",
)
@@ -60,17 +84,18 @@ def clear_staging_env():
engines.testing_reaper.close_all()
shutil.rmtree(_get_staging_directory(), True)
+ _restore_logger()
def script_file_fixture(txt):
- dir_ = os.path.join(_get_staging_directory(), "scripts")
- path = os.path.join(dir_, "script.py.mako")
+ dir_ = _join_path(_get_staging_directory(), "scripts")
+ path = _join_path(dir_, "script.py.mako")
with open(path, "w") as f:
f.write(txt)
def env_file_fixture(txt):
- dir_ = os.path.join(_get_staging_directory(), "scripts")
+ dir_ = _join_path(_get_staging_directory(), "scripts")
txt = (
"""
from alembic import context
@@ -80,7 +105,7 @@ def env_file_fixture(txt):
+ txt
)
- path = os.path.join(dir_, "env.py")
+ path = _join_path(dir_, "env.py")
pyc_path = util.pyc_file_from_path(path)
if pyc_path:
os.unlink(pyc_path)
@@ -90,26 +115,26 @@ def env_file_fixture(txt):
def _sqlite_file_db(tempname="foo.db", future=False, scope=None, **options):
- dir_ = os.path.join(_get_staging_directory(), "scripts")
+ dir_ = _join_path(_get_staging_directory(), "scripts")
url = "sqlite:///%s/%s" % (dir_, tempname)
- if scope and util.sqla_14:
+ if scope:
options["scope"] = scope
return testing_util.testing_engine(url=url, future=future, options=options)
def _sqlite_testing_config(sourceless=False, future=False):
- dir_ = os.path.join(_get_staging_directory(), "scripts")
- url = "sqlite:///%s/foo.db" % dir_
+ dir_ = _join_path(_get_staging_directory(), "scripts")
+ url = f"sqlite:///{dir_}/foo.db"
sqlalchemy_future = future or ("future" in config.db.__class__.__module__)
return _write_config_file(
- """
+ f"""
[alembic]
-script_location = %s
-sqlalchemy.url = %s
-sourceless = %s
-%s
+script_location = {dir_}
+sqlalchemy.url = {url}
+sourceless = {"true" if sourceless else "false"}
+{"sqlalchemy.future = true" if sqlalchemy_future else ""}
[loggers]
keys = root,sqlalchemy
@@ -140,29 +165,25 @@ class = StreamHandler
format = %%(levelname)-5.5s [%%(name)s] %%(message)s
datefmt = %%H:%%M:%%S
"""
- % (
- dir_,
- url,
- "true" if sourceless else "false",
- "sqlalchemy.future = true" if sqlalchemy_future else "",
- )
)
def _multi_dir_testing_config(sourceless=False, extra_version_location=""):
- dir_ = os.path.join(_get_staging_directory(), "scripts")
+ dir_ = _join_path(_get_staging_directory(), "scripts")
sqlalchemy_future = "future" in config.db.__class__.__module__
url = "sqlite:///%s/foo.db" % dir_
return _write_config_file(
- """
+ f"""
[alembic]
-script_location = %s
-sqlalchemy.url = %s
-sqlalchemy.future = %s
-sourceless = %s
-version_locations = %%(here)s/model1/ %%(here)s/model2/ %%(here)s/model3/ %s
+script_location = {dir_}
+sqlalchemy.url = {url}
+sqlalchemy.future = {"true" if sqlalchemy_future else "false"}
+sourceless = {"true" if sourceless else "false"}
+path_separator = space
+version_locations = %(here)s/model1/ %(here)s/model2/ %(here)s/model3/ \
+{extra_version_location}
[loggers]
keys = root
@@ -188,26 +209,63 @@ class = StreamHandler
format = %%(levelname)-5.5s [%%(name)s] %%(message)s
datefmt = %%H:%%M:%%S
"""
- % (
- dir_,
- url,
- "true" if sqlalchemy_future else "false",
- "true" if sourceless else "false",
- extra_version_location,
- )
+ )
+
+
+def _no_sql_pyproject_config(dialect="postgresql", directives=""):
+ """use a postgresql url with no host so that
+ connections guaranteed to fail"""
+ dir_ = _join_path(_get_staging_directory(), "scripts")
+
+ return _write_toml_config(
+ f"""
+[tool.alembic]
+script_location ="{dir_}"
+{textwrap.dedent(directives)}
+
+ """,
+ f"""
+[alembic]
+sqlalchemy.url = {dialect}://
+
+[loggers]
+keys = root
+
+[handlers]
+keys = console
+
+[logger_root]
+level = WARNING
+handlers = console
+qualname =
+
+[handler_console]
+class = StreamHandler
+args = (sys.stderr,)
+level = NOTSET
+formatter = generic
+
+[formatters]
+keys = generic
+
+[formatter_generic]
+format = %%(levelname)-5.5s [%%(name)s] %%(message)s
+datefmt = %%H:%%M:%%S
+
+""",
)
def _no_sql_testing_config(dialect="postgresql", directives=""):
"""use a postgresql url with no host so that
connections guaranteed to fail"""
- dir_ = os.path.join(_get_staging_directory(), "scripts")
+ dir_ = _join_path(_get_staging_directory(), "scripts")
return _write_config_file(
- """
+ f"""
[alembic]
-script_location = %s
-sqlalchemy.url = %s://
-%s
+script_location ={dir_}
+sqlalchemy.url = {dialect}://
+{directives}
[loggers]
keys = root
@@ -234,10 +292,16 @@ class = StreamHandler
datefmt = %%H:%%M:%%S
"""
- % (dir_, dialect, directives)
)
+def _write_toml_config(tomltext, initext):
+ cfg = _write_config_file(initext)
+ with open(cfg.toml_file_name, "w") as f:
+ f.write(tomltext)
+ return cfg
+
+
def _write_config_file(text):
cfg = _testing_config()
with open(cfg.config_file_name, "w") as f:
@@ -250,7 +314,10 @@ def _testing_config():
if not os.access(_get_staging_directory(), os.F_OK):
os.mkdir(_get_staging_directory())
- return Config(os.path.join(_get_staging_directory(), "test_alembic.ini"))
+ return Config(
+ _join_path(_get_staging_directory(), "test_alembic.ini"),
+ _join_path(_get_staging_directory(), "pyproject.toml"),
+ )
def write_script(
@@ -270,9 +337,7 @@ def write_script(
script = Script._from_path(scriptdir, path)
old = scriptdir.revision_map.get_revision(script.revision)
if old.down_revision != script.down_revision:
- raise Exception(
- "Can't change down_revision " "on a refresh operation."
- )
+ raise Exception("Can't change down_revision on a refresh operation.")
scriptdir.revision_map.add_revision(script, _replace=True)
if sourceless:
@@ -312,9 +377,9 @@ def three_rev_fixture(cfg):
write_script(
script,
a,
- """\
+ f"""\
"Rev A"
-revision = '%s'
+revision = '{a}'
down_revision = None
from alembic import op
@@ -327,8 +392,7 @@ def upgrade():
def downgrade():
op.execute("DROP STEP 1")
-"""
- % a,
+""",
)
script.generate_revision(b, "revision b", refresh=True, head=a)
@@ -358,10 +422,10 @@ def downgrade():
write_script(
script,
c,
- """\
+ f"""\
"Rev C"
-revision = '%s'
-down_revision = '%s'
+revision = '{c}'
+down_revision = '{b}'
from alembic import op
@@ -373,8 +437,7 @@ def upgrade():
def downgrade():
op.execute("DROP STEP 3")
-"""
- % (c, b),
+""",
)
return a, b, c
@@ -396,10 +459,10 @@ def multi_heads_fixture(cfg, a, b, c):
write_script(
script,
d,
- """\
+ f"""\
"Rev D"
-revision = '%s'
-down_revision = '%s'
+revision = '{d}'
+down_revision = '{b}'
from alembic import op
@@ -411,8 +474,7 @@ def upgrade():
def downgrade():
op.execute("DROP STEP 4")
-"""
- % (d, b),
+""",
)
script.generate_revision(
@@ -421,10 +483,10 @@ def downgrade():
write_script(
script,
e,
- """\
+ f"""\
"Rev E"
-revision = '%s'
-down_revision = '%s'
+revision = '{e}'
+down_revision = '{d}'
from alembic import op
@@ -436,8 +498,7 @@ def upgrade():
def downgrade():
op.execute("DROP STEP 5")
-"""
- % (e, d),
+""",
)
script.generate_revision(
@@ -446,10 +507,10 @@ def downgrade():
write_script(
script,
f,
- """\
+ f"""\
"Rev F"
-revision = '%s'
-down_revision = '%s'
+revision = '{f}'
+down_revision = '{b}'
from alembic import op
@@ -461,8 +522,7 @@ def upgrade():
def downgrade():
op.execute("DROP STEP 6")
-"""
- % (f, b),
+""",
)
return d, e, f
@@ -471,25 +531,25 @@ def downgrade():
def _multidb_testing_config(engines):
"""alembic.ini fixture to work exactly with the 'multidb' template"""
- dir_ = os.path.join(_get_staging_directory(), "scripts")
+ dir_ = _join_path(_get_staging_directory(), "scripts")
sqlalchemy_future = "future" in config.db.__class__.__module__
databases = ", ".join(engines.keys())
engines = "\n\n".join(
- "[%s]\n" "sqlalchemy.url = %s" % (key, value.url)
+ f"[{key}]\nsqlalchemy.url = {value.url}"
for key, value in engines.items()
)
return _write_config_file(
- """
+ f"""
[alembic]
-script_location = %s
+script_location = {dir_}
sourceless = false
-sqlalchemy.future = %s
-databases = %s
+sqlalchemy.future = {"true" if sqlalchemy_future else "false"}
+databases = {databases}
-%s
+{engines}
[loggers]
keys = root
@@ -514,5 +574,8 @@ class = StreamHandler
format = %%(levelname)-5.5s [%%(name)s] %%(message)s
datefmt = %%H:%%M:%%S
"""
- % (dir_, "true" if sqlalchemy_future else "false", databases, engines)
)
+
+
+def _join_path(base: str, *more: str):
+ return str(Path(base).joinpath(*more).as_posix())
diff --git a/libs/alembic/testing/fixtures.py b/libs/alembic/testing/fixtures.py
index 3b5ce596e6..73e421259d 100644
--- a/libs/alembic/testing/fixtures.py
+++ b/libs/alembic/testing/fixtures.py
@@ -3,11 +3,17 @@
import configparser
from contextlib import contextmanager
import io
+import os
import re
+import shutil
from typing import Any
from typing import Dict
+from typing import Generator
+from typing import Literal
+from typing import overload
from sqlalchemy import Column
+from sqlalchemy import create_mock_engine
from sqlalchemy import inspect
from sqlalchemy import MetaData
from sqlalchemy import String
@@ -17,20 +23,20 @@
from sqlalchemy.testing import config
from sqlalchemy.testing import mock
from sqlalchemy.testing.assertions import eq_
+from sqlalchemy.testing.fixtures import FutureEngineMixin
from sqlalchemy.testing.fixtures import TablesTest as SQLAlchemyTablesTest
from sqlalchemy.testing.fixtures import TestBase as SQLAlchemyTestBase
+from sqlalchemy.testing.util import drop_all_tables_from_metadata
import alembic
from .assertions import _get_dialect
+from .env import _get_staging_directory
from ..environment import EnvironmentContext
from ..migration import MigrationContext
from ..operations import Operations
from ..util import sqla_compat
-from ..util.sqla_compat import create_mock_engine
-from ..util.sqla_compat import sqla_14
from ..util.sqla_compat import sqla_2
-
testing_config = configparser.ConfigParser()
testing_config.read(["test.cfg"])
@@ -38,6 +44,31 @@
class TestBase(SQLAlchemyTestBase):
is_sqlalchemy_future = sqla_2
+ @testing.fixture()
+ def clear_staging_dir(self):
+ yield
+ location = _get_staging_directory()
+ for filename in os.listdir(location):
+ file_path = os.path.join(location, filename)
+ if os.path.isfile(file_path) or os.path.islink(file_path):
+ os.unlink(file_path)
+ elif os.path.isdir(file_path):
+ shutil.rmtree(file_path)
+
+ @contextmanager
+ def pushd(self, dirname) -> Generator[None, None, None]:
+ current_dir = os.getcwd()
+ try:
+ os.chdir(dirname)
+ yield
+ finally:
+ os.chdir(current_dir)
+
+ @testing.fixture()
+ def pop_alembic_config_env(self):
+ yield
+ os.environ.pop("ALEMBIC_CONFIG", None)
+
@testing.fixture()
def ops_context(self, migration_context):
with migration_context.begin_transaction(_per_migration=True):
@@ -57,20 +88,61 @@ def as_sql_migration_context(self, connection):
@testing.fixture
def connection(self):
+ global _connection_fixture_connection
+
with config.db.connect() as conn:
+ _connection_fixture_connection = conn
yield conn
+ _connection_fixture_connection = None
-class TablesTest(TestBase, SQLAlchemyTablesTest):
- pass
+ @testing.fixture
+ def restore_operations(self):
+ """Restore runners for modified operations"""
+
+ saved_impls = None
+ op_cls = None
+
+ def _save_attrs(_op_cls):
+ nonlocal saved_impls, op_cls
+ saved_impls = _op_cls._to_impl._registry.copy()
+ op_cls = _op_cls
+
+ yield _save_attrs
+
+ if op_cls is not None and saved_impls is not None:
+ op_cls._to_impl._registry = saved_impls
+
+ @config.fixture()
+ def metadata(self, request):
+ """Provide bound MetaData for a single test, dropping afterwards."""
+
+ from sqlalchemy.sql import schema
+
+ metadata = schema.MetaData()
+ request.instance.metadata = metadata
+ yield metadata
+ del request.instance.metadata
+
+ if (
+ _connection_fixture_connection
+ and _connection_fixture_connection.in_transaction()
+ ):
+ trans = _connection_fixture_connection.get_transaction()
+ trans.rollback()
+ with _connection_fixture_connection.begin():
+ drop_all_tables_from_metadata(
+ metadata, _connection_fixture_connection
+ )
+ else:
+ drop_all_tables_from_metadata(metadata, config.db)
-if sqla_14:
- from sqlalchemy.testing.fixtures import FutureEngineMixin
-else:
+_connection_fixture_connection = None
- class FutureEngineMixin: # type:ignore[no-redef]
- __requires__ = ("sqlalchemy_14",)
+
+class TablesTest(TestBase, SQLAlchemyTablesTest):
+ pass
FutureEngineMixin.is_sqlalchemy_future = True
@@ -89,8 +161,24 @@ def dump(sql, *multiparams, **params):
_engs: Dict[Any, Any] = {}
+@overload
+@contextmanager
+def capture_context_buffer(
+ bytes_io: Literal[True], **kw: Any
+) -> Generator[io.BytesIO, None, None]: ...
+
+
+@overload
@contextmanager
-def capture_context_buffer(**kw):
+def capture_context_buffer(
+ **kw: Any,
+) -> Generator[io.StringIO, None, None]: ...
+
+
+@contextmanager
+def capture_context_buffer(
+ **kw: Any,
+) -> Generator[io.StringIO | io.BytesIO, None, None]:
if kw.pop("bytes_io", False):
buf = io.BytesIO()
else:
@@ -108,7 +196,9 @@ def configure(*arg, **opt):
@contextmanager
-def capture_engine_context_buffer(**kw):
+def capture_engine_context_buffer(
+ **kw: Any,
+) -> Generator[io.StringIO, None, None]:
from .env import _sqlite_file_db
from sqlalchemy import event
@@ -190,12 +280,8 @@ def assert_contains(self, sql):
opts["as_sql"] = as_sql
if literal_binds:
opts["literal_binds"] = literal_binds
- if not sqla_14 and dialect == "mariadb":
- ctx_dialect = _get_dialect("mysql")
- ctx_dialect.server_version_info = (10, 4, 0, "MariaDB")
- else:
- ctx_dialect = _get_dialect(dialect)
+ ctx_dialect = _get_dialect(dialect)
if native_boolean is not None:
ctx_dialect.supports_native_boolean = native_boolean
# this is new as of SQLAlchemy 1.2.7 and is used by SQL Server,
diff --git a/libs/alembic/testing/requirements.py b/libs/alembic/testing/requirements.py
index 6e07e28ea4..1b217c937a 100644
--- a/libs/alembic/testing/requirements.py
+++ b/libs/alembic/testing/requirements.py
@@ -1,7 +1,6 @@
from sqlalchemy.testing.requirements import Requirements
from alembic import util
-from alembic.util import sqla_compat
from ..testing import exclusions
@@ -74,13 +73,6 @@ def reflects_pk_names(self):
def reflects_fk_options(self):
return exclusions.closed()
- @property
- def sqlalchemy_14(self):
- return exclusions.skip_if(
- lambda config: not util.sqla_14,
- "SQLAlchemy 1.4 or greater required",
- )
-
@property
def sqlalchemy_1x(self):
return exclusions.skip_if(
@@ -105,7 +97,7 @@ def go(config):
else:
return True
- return self.sqlalchemy_14 + exclusions.only_if(go)
+ return exclusions.only_if(go)
@property
def comments(self):
@@ -122,24 +114,13 @@ def computed_columns(self):
return exclusions.closed()
@property
- def computed_columns_api(self):
- return exclusions.only_if(
- exclusions.BooleanPredicate(sqla_compat.has_computed)
- )
-
- @property
- def computed_reflects_normally(self):
- return exclusions.only_if(
- exclusions.BooleanPredicate(sqla_compat.has_computed_reflection)
- )
-
- @property
- def computed_reflects_as_server_default(self):
- return exclusions.closed()
+ def computed_columns_warn_no_persisted(self):
+ def go(config):
+ return hasattr(
+ config.db.dialect, "supports_virtual_generated_columns"
+ )
- @property
- def computed_doesnt_reflect_as_server_default(self):
- return exclusions.closed()
+ return exclusions.only_if("postgresql<18") + exclusions.only_if(go)
@property
def autoincrement_on_composite_pk(self):
@@ -183,6 +164,10 @@ def fk_deferrable_is_reflected(self):
@property
def fk_names(self):
+ return self.foreign_key_name_reflection
+
+ @property
+ def foreign_key_name_reflection(self):
return exclusions.open()
@property
@@ -202,9 +187,3 @@ def identity_columns(self):
@property
def identity_columns_alter(self):
return exclusions.closed()
-
- @property
- def identity_columns_api(self):
- return exclusions.only_if(
- exclusions.BooleanPredicate(sqla_compat.has_identity)
- )
diff --git a/libs/alembic/testing/suite/_autogen_fixtures.py b/libs/alembic/testing/suite/_autogen_fixtures.py
index d838ebef10..8329a1ac89 100644
--- a/libs/alembic/testing/suite/_autogen_fixtures.py
+++ b/libs/alembic/testing/suite/_autogen_fixtures.py
@@ -2,6 +2,8 @@
from typing import Any
from typing import Dict
+from typing import Literal
+from typing import overload
from typing import Set
from sqlalchemy import CHAR
@@ -14,6 +16,7 @@
from sqlalchemy import Integer
from sqlalchemy import MetaData
from sqlalchemy import Numeric
+from sqlalchemy import PrimaryKeyConstraint
from sqlalchemy import String
from sqlalchemy import Table
from sqlalchemy import Text
@@ -149,6 +152,118 @@ def _get_model_schema(cls):
return m
+class NamingConvModel:
+ __requires__ = ("unique_constraint_reflection",)
+ configure_opts = {"conv_all_constraint_names": True}
+ naming_convention = {
+ "ix": "ix_%(column_0_label)s",
+ "uq": "uq_%(table_name)s_%(constraint_name)s",
+ "ck": "ck_%(table_name)s_%(constraint_name)s",
+ "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
+ "pk": "pk_%(table_name)s",
+ }
+
+ @classmethod
+ def _get_db_schema(cls):
+ # database side - assume all constraints have a name that
+ # we would assume here is a "db generated" name. need to make
+ # sure these all render with op.f().
+ m = MetaData()
+ Table(
+ "x1",
+ m,
+ Column("q", Integer),
+ Index("db_x1_index_q", "q"),
+ PrimaryKeyConstraint("q", name="db_x1_primary_q"),
+ )
+ Table(
+ "x2",
+ m,
+ Column("q", Integer),
+ Column("p", ForeignKey("x1.q", name="db_x2_foreign_q")),
+ CheckConstraint("q > 5", name="db_x2_check_q"),
+ )
+ Table(
+ "x3",
+ m,
+ Column("q", Integer),
+ Column("r", Integer),
+ Column("s", Integer),
+ UniqueConstraint("q", name="db_x3_unique_q"),
+ )
+ Table(
+ "x4",
+ m,
+ Column("q", Integer),
+ PrimaryKeyConstraint("q", name="db_x4_primary_q"),
+ )
+ Table(
+ "x5",
+ m,
+ Column("q", Integer),
+ Column("p", ForeignKey("x4.q", name="db_x5_foreign_q")),
+ Column("r", Integer),
+ Column("s", Integer),
+ PrimaryKeyConstraint("q", name="db_x5_primary_q"),
+ UniqueConstraint("r", name="db_x5_unique_r"),
+ CheckConstraint("s > 5", name="db_x5_check_s"),
+ )
+ # SQLite and it's "no names needed" thing. bleh.
+ # we can't have a name for these so you'll see "None" for the name.
+ Table(
+ "unnamed_sqlite",
+ m,
+ Column("q", Integer),
+ Column("r", Integer),
+ PrimaryKeyConstraint("q"),
+ UniqueConstraint("r"),
+ )
+ return m
+
+ @classmethod
+ def _get_model_schema(cls):
+ from sqlalchemy.sql.naming import conv
+
+ m = MetaData(naming_convention=cls.naming_convention)
+ Table(
+ "x1", m, Column("q", Integer, primary_key=True), Index(None, "q")
+ )
+ Table(
+ "x2",
+ m,
+ Column("q", Integer),
+ Column("p", ForeignKey("x1.q")),
+ CheckConstraint("q > 5", name="token_x2check1"),
+ )
+ Table(
+ "x3",
+ m,
+ Column("q", Integer),
+ Column("r", Integer),
+ Column("s", Integer),
+ UniqueConstraint("r", name="token_x3r"),
+ UniqueConstraint("s", name=conv("userdef_x3_unique_s")),
+ )
+ Table(
+ "x4",
+ m,
+ Column("q", Integer, primary_key=True),
+ Index("userdef_x4_idx_q", "q"),
+ )
+ Table(
+ "x6",
+ m,
+ Column("q", Integer, primary_key=True),
+ Column("p", ForeignKey("x4.q")),
+ Column("r", Integer),
+ Column("s", Integer),
+ UniqueConstraint("r", name="token_x6r"),
+ CheckConstraint("s > 5", "token_x6check1"),
+ CheckConstraint("s < 20", conv("userdef_x6_check_s")),
+ )
+ return m
+
+
class _ComparesFKs:
def _assert_fk_diff(
self,
@@ -268,17 +383,46 @@ def _update_context(
class AutogenFixtureTest(_ComparesFKs):
+
+ @overload
def _fixture(
self,
- m1,
- m2,
+ m1: MetaData,
+ m2: MetaData,
+ include_schemas=...,
+ opts=...,
+ object_filters=...,
+ name_filters=...,
+ *,
+ return_ops: Literal[True],
+ max_identifier_length=...,
+ ) -> ops.UpgradeOps: ...
+
+ @overload
+ def _fixture(
+ self,
+ m1: MetaData,
+ m2: MetaData,
+ include_schemas=...,
+ opts=...,
+ object_filters=...,
+ name_filters=...,
+ *,
+ return_ops: Literal[False] = ...,
+ max_identifier_length=...,
+ ) -> list[Any]: ...
+
+ def _fixture(
+ self,
+ m1: MetaData,
+ m2: MetaData,
include_schemas=False,
opts=None,
object_filters=_default_object_filters,
name_filters=_default_name_filters,
- return_ops=False,
+ return_ops: bool = False,
max_identifier_length=None,
- ):
+ ) -> ops.UpgradeOps | list[Any]:
if max_identifier_length:
dialect = self.bind.dialect
existing_length = dialect.max_identifier_length
diff --git a/libs/alembic/testing/suite/test_autogen_computed.py b/libs/alembic/testing/suite/test_autogen_computed.py
index 04a3caf072..586691b265 100644
--- a/libs/alembic/testing/suite/test_autogen_computed.py
+++ b/libs/alembic/testing/suite/test_autogen_computed.py
@@ -1,3 +1,5 @@
+from contextlib import nullcontext
+
import sqlalchemy as sa
from sqlalchemy import Column
from sqlalchemy import Integer
@@ -8,7 +10,7 @@
from ... import testing
from ...testing import config
from ...testing import eq_
-from ...testing import exclusions
+from ...testing import expect_warnings
from ...testing import is_
from ...testing import is_true
from ...testing import mock
@@ -19,6 +21,13 @@ class AutogenerateComputedTest(AutogenFixtureTest, TestBase):
__requires__ = ("computed_columns",)
__backend__ = True
+ def _fixture_ctx(self):
+ if config.requirements.computed_columns_warn_no_persisted.enabled:
+ ctx = expect_warnings()
+ else:
+ ctx = nullcontext()
+ return ctx
+
def test_add_computed_column(self):
m1 = MetaData()
m2 = MetaData()
@@ -32,7 +41,8 @@ def test_add_computed_column(self):
Column("foo", Integer, sa.Computed("5")),
)
- diffs = self._fixture(m1, m2)
+ with self._fixture_ctx():
+ diffs = self._fixture(m1, m2)
eq_(diffs[0][0], "add_column")
eq_(diffs[0][2], "user")
@@ -56,25 +66,16 @@ def test_remove_computed_column(self):
Table("user", m2, Column("id", Integer, primary_key=True))
- diffs = self._fixture(m1, m2)
+ with self._fixture_ctx():
+ diffs = self._fixture(m1, m2)
eq_(diffs[0][0], "remove_column")
eq_(diffs[0][2], "user")
c = diffs[0][3]
eq_(c.name, "foo")
- if config.requirements.computed_reflects_normally.enabled:
- is_true(isinstance(c.computed, sa.Computed))
- else:
- is_(c.computed, None)
-
- if config.requirements.computed_reflects_as_server_default.enabled:
- is_true(isinstance(c.server_default, sa.DefaultClause))
- eq_(str(c.server_default.arg.text), "5")
- elif config.requirements.computed_reflects_normally.enabled:
- is_true(isinstance(c.computed, sa.Computed))
- else:
- is_(c.computed, None)
+ is_true(isinstance(c.computed, sa.Computed))
+ is_true(isinstance(c.server_default, sa.Computed))
@testing.combinations(
lambda: (None, sa.Computed("bar*5")),
@@ -85,7 +86,6 @@ def test_remove_computed_column(self):
),
lambda: (sa.Computed("bar*5"), sa.Computed("bar * 42")),
)
- @config.requirements.computed_reflects_normally
def test_cant_change_computed_warning(self, test_case):
arg_before, arg_after = testing.resolve_lambda(test_case, **locals())
m1 = MetaData()
@@ -110,7 +110,7 @@ def test_cant_change_computed_warning(self, test_case):
Column("foo", Integer, *arg_after),
)
- with mock.patch("alembic.util.warn") as mock_warn:
+ with mock.patch("alembic.util.warn") as mock_warn, self._fixture_ctx():
diffs = self._fixture(m1, m2)
eq_(
@@ -125,10 +125,6 @@ def test_cant_change_computed_warning(self, test_case):
lambda: (sa.Computed("5"), sa.Computed("5")),
lambda: (sa.Computed("bar*5"), sa.Computed("bar*5")),
lambda: (sa.Computed("bar*5"), sa.Computed("bar * \r\n\t5")),
- (
- lambda: (sa.Computed("bar*5"), None),
- config.requirements.computed_doesnt_reflect_as_server_default,
- ),
)
def test_computed_unchanged(self, test_case):
arg_before, arg_after = testing.resolve_lambda(test_case, **locals())
@@ -154,51 +150,8 @@ def test_computed_unchanged(self, test_case):
Column("foo", Integer, *arg_after),
)
- with mock.patch("alembic.util.warn") as mock_warn:
+ with mock.patch("alembic.util.warn") as mock_warn, self._fixture_ctx():
diffs = self._fixture(m1, m2)
eq_(mock_warn.mock_calls, [])
eq_(list(diffs), [])
-
- @config.requirements.computed_reflects_as_server_default
- def test_remove_computed_default_on_computed(self):
- """Asserts the current behavior which is that on PG and Oracle,
- the GENERATED ALWAYS AS is reflected as a server default which we can't
- tell is actually "computed", so these come out as a modification to
- the server default.
-
- """
- m1 = MetaData()
- m2 = MetaData()
-
- Table(
- "user",
- m1,
- Column("id", Integer, primary_key=True),
- Column("bar", Integer),
- Column("foo", Integer, sa.Computed("bar + 42")),
- )
-
- Table(
- "user",
- m2,
- Column("id", Integer, primary_key=True),
- Column("bar", Integer),
- Column("foo", Integer),
- )
-
- diffs = self._fixture(m1, m2)
-
- eq_(diffs[0][0][0], "modify_default")
- eq_(diffs[0][0][2], "user")
- eq_(diffs[0][0][3], "foo")
- old = diffs[0][0][-2]
- new = diffs[0][0][-1]
-
- is_(new, None)
- is_true(isinstance(old, sa.DefaultClause))
-
- if exclusions.against(config, "postgresql"):
- eq_(str(old.arg.text), "(bar + 42)")
- elif exclusions.against(config, "oracle"):
- eq_(str(old.arg.text), '"BAR"+42')
diff --git a/libs/alembic/testing/suite/test_autogen_fks.py b/libs/alembic/testing/suite/test_autogen_fks.py
index 0240b98d38..d69736e64d 100644
--- a/libs/alembic/testing/suite/test_autogen_fks.py
+++ b/libs/alembic/testing/suite/test_autogen_fks.py
@@ -199,6 +199,7 @@ def test_no_change_composite_fk(self):
eq_(diffs, [])
+ @config.requirements.foreign_key_name_reflection
def test_casing_convention_changed_so_put_drops_first(self):
m1 = MetaData()
m2 = MetaData()
@@ -247,7 +248,7 @@ def test_casing_convention_changed_so_put_drops_first(self):
["test2"],
"some_table",
["test"],
- name="MyFK" if config.requirements.fk_names.enabled else None,
+ name="MyFK",
)
self._assert_fk_diff(
diff --git a/libs/alembic/testing/warnings.py b/libs/alembic/testing/warnings.py
index e87136b85f..86d45a0dd5 100644
--- a/libs/alembic/testing/warnings.py
+++ b/libs/alembic/testing/warnings.py
@@ -10,8 +10,6 @@
from sqlalchemy import exc as sa_exc
-from ..util import sqla_14
-
def setup_filters():
"""Set global warning behavior for the test suite."""
@@ -23,13 +21,6 @@ def setup_filters():
# some selected deprecations...
warnings.filterwarnings("error", category=DeprecationWarning)
- if not sqla_14:
- # 1.3 uses pkg_resources in PluginLoader
- warnings.filterwarnings(
- "ignore",
- "pkg_resources is deprecated as an API",
- DeprecationWarning,
- )
try:
import pytest
except ImportError:
diff --git a/libs/alembic/util/__init__.py b/libs/alembic/util/__init__.py
index 4724e1f084..8f3f685b44 100644
--- a/libs/alembic/util/__init__.py
+++ b/libs/alembic/util/__init__.py
@@ -1,15 +1,19 @@
from .editor import open_in_editor as open_in_editor
from .exc import AutogenerateDiffsDetected as AutogenerateDiffsDetected
from .exc import CommandError as CommandError
+from .exc import DatabaseNotAtHead as DatabaseNotAtHead
from .langhelpers import _with_legacy_names as _with_legacy_names
from .langhelpers import asbool as asbool
from .langhelpers import dedupe_tuple as dedupe_tuple
from .langhelpers import Dispatcher as Dispatcher
+from .langhelpers import DispatchPriority as DispatchPriority
from .langhelpers import EMPTY_DICT as EMPTY_DICT
from .langhelpers import immutabledict as immutabledict
from .langhelpers import memoized_property as memoized_property
from .langhelpers import ModuleClsProxy as ModuleClsProxy
from .langhelpers import not_none as not_none
+from .langhelpers import PriorityDispatcher as PriorityDispatcher
+from .langhelpers import PriorityDispatchResult as PriorityDispatchResult
from .langhelpers import rev_id as rev_id
from .langhelpers import to_list as to_list
from .langhelpers import to_tuple as to_tuple
@@ -20,16 +24,10 @@
from .messaging import obfuscate_url_pw as obfuscate_url_pw
from .messaging import status as status
from .messaging import warn as warn
+from .messaging import warn_deprecated as warn_deprecated
from .messaging import write_outstream as write_outstream
from .pyfiles import coerce_resource_to_filename as coerce_resource_to_filename
from .pyfiles import load_python_file as load_python_file
from .pyfiles import pyc_file_from_path as pyc_file_from_path
from .pyfiles import template_to_file as template_to_file
-from .sqla_compat import has_computed as has_computed
-from .sqla_compat import sqla_13 as sqla_13
-from .sqla_compat import sqla_14 as sqla_14
from .sqla_compat import sqla_2 as sqla_2
-
-
-if not sqla_13:
- raise CommandError("SQLAlchemy 1.3.0 or greater is required.")
diff --git a/libs/alembic/util/compat.py b/libs/alembic/util/compat.py
index e185cc4172..527ffdc97d 100644
--- a/libs/alembic/util/compat.py
+++ b/libs/alembic/util/compat.py
@@ -3,15 +3,16 @@
from __future__ import annotations
from configparser import ConfigParser
+from importlib import metadata
+from importlib.metadata import EntryPoint
import io
import os
+from pathlib import Path
import sys
import typing
from typing import Any
-from typing import List
-from typing import Optional
+from typing import Iterator
from typing import Sequence
-from typing import Union
if True:
# zimports hack for too-long names
@@ -24,9 +25,10 @@
is_posix = os.name == "posix"
+py314 = sys.version_info >= (3, 14)
+py313 = sys.version_info >= (3, 13)
+py312 = sys.version_info >= (3, 12)
py311 = sys.version_info >= (3, 11)
-py310 = sys.version_info >= (3, 10)
-py39 = sys.version_info >= (3, 9)
# produce a wrapper that allows encoded text to stream
@@ -37,30 +39,72 @@ def close(self) -> None:
pass
-if py39:
- from importlib import resources as _resources
+if py311:
+ import tomllib as tomllib
+else:
+ import tomli as tomllib # type: ignore # noqa
+
+
+if py312:
+
+ def path_walk(
+ path: Path, *, top_down: bool = True
+ ) -> Iterator[tuple[Path, list[str], list[str]]]:
+ return Path.walk(path)
- importlib_resources = _resources
- from importlib import metadata as _metadata
+ def path_relative_to(
+ path: Path, other: Path, *, walk_up: bool = False
+ ) -> Path:
+ return path.relative_to(other, walk_up=walk_up)
- importlib_metadata = _metadata
- from importlib.metadata import EntryPoint as EntryPoint
else:
- import importlib_resources # type:ignore # noqa
- import importlib_metadata # type:ignore # noqa
- from importlib_metadata import EntryPoint # type:ignore # noqa
+
+ def path_walk(
+ path: Path, *, top_down: bool = True
+ ) -> Iterator[tuple[Path, list[str], list[str]]]:
+ for root, dirs, files in os.walk(path, topdown=top_down):
+ yield Path(root), dirs, files
+
+ def path_relative_to(
+ path: Path, other: Path, *, walk_up: bool = False
+ ) -> Path:
+ """
+ Calculate the relative path of 'path' with respect to 'other',
+ optionally allowing 'path' to be outside the subtree of 'other'.
+
+ OK I used AI for this, sorry
+
+ """
+ try:
+ return path.relative_to(other)
+ except ValueError:
+ if walk_up:
+ other_ancestors = list(other.parents) + [other]
+ for ancestor in other_ancestors:
+ try:
+ return path.relative_to(ancestor)
+ except ValueError:
+ continue
+ raise ValueError(
+ f"{path} is not in the same subtree as {other}"
+ )
+ else:
+ raise
def importlib_metadata_get(group: str) -> Sequence[EntryPoint]:
- ep = importlib_metadata.entry_points()
- if hasattr(ep, "select"):
- return ep.select(group=group)
- else:
- return ep.get(group, ()) # type: ignore
+ """provide a facade for metadata.entry_points().
+
+ This is no longer a "compat" function as of Python 3.10, however
+ the function is widely referenced in the test suite and elsewhere so is
+ still in this module for compatibility reasons.
+
+ """
+ return metadata.entry_points().select(group=group)
def formatannotation_fwdref(
- annotation: Any, base_module: Optional[Any] = None
+ annotation: Any, base_module: Any | None = None
) -> str:
"""vendored from python 3.7"""
# copied over _formatannotation from sqlalchemy 2.0
@@ -81,9 +125,6 @@ def formatannotation_fwdref(
def read_config_parser(
file_config: ConfigParser,
- file_argument: Sequence[Union[str, os.PathLike[str]]],
-) -> List[str]:
- if py310:
- return file_config.read(file_argument, encoding="locale")
- else:
- return file_config.read(file_argument)
+ file_argument: list[str | os.PathLike[str]],
+) -> list[str]:
+ return file_config.read(file_argument, encoding="locale")
diff --git a/libs/alembic/util/exc.py b/libs/alembic/util/exc.py
index 0d0496b1e2..4658f7823d 100644
--- a/libs/alembic/util/exc.py
+++ b/libs/alembic/util/exc.py
@@ -1,6 +1,43 @@
+from __future__ import annotations
+
+from typing import Any
+from typing import List
+from typing import Tuple
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from alembic.autogenerate import RevisionContext
+
+
class CommandError(Exception):
- pass
+ """Base command error for all exceptions"""
+
+
+class DatabaseNotAtHead(CommandError):
+ """Indicates the database is not at current head revisions.
+
+ Raised by the :func:`.command.current` command when the
+ :paramref:`.command.current.check_heads` parameter is used.
+
+ .. versionadded:: 1.17.1
+
+ """
class AutogenerateDiffsDetected(CommandError):
- pass
+ """Raised when diffs were detected by the :func:`.command.check`
+ command.
+
+ .. versionadded:: 1.9.0
+
+ """
+
+ def __init__(
+ self,
+ message: str,
+ revision_context: RevisionContext,
+ diffs: List[Tuple[Any, ...]],
+ ) -> None:
+ super().__init__(message)
+ self.revision_context = revision_context
+ self.diffs = diffs
diff --git a/libs/alembic/util/langhelpers.py b/libs/alembic/util/langhelpers.py
index 80d88cbcec..cf0df2396a 100644
--- a/libs/alembic/util/langhelpers.py
+++ b/libs/alembic/util/langhelpers.py
@@ -2,6 +2,7 @@
import collections
from collections.abc import Iterable
+import enum
import textwrap
from typing import Any
from typing import Callable
@@ -17,9 +18,7 @@
from typing import Set
from typing import Tuple
from typing import Type
-from typing import TYPE_CHECKING
from typing import TypeVar
-from typing import Union
import uuid
import warnings
@@ -264,20 +263,63 @@ def dedupe_tuple(tup: Tuple[str, ...]) -> Tuple[str, ...]:
return tuple(unique_list(tup))
+class PriorityDispatchResult(enum.Enum):
+ """indicate an action after running a function within a
+ :class:`.PriorityDispatcher`
+
+ .. versionadded:: 1.18.0
+
+ """
+
+ CONTINUE = enum.auto()
+ """Continue running more functions.
+
+ Any return value that is not PriorityDispatchResult.STOP is equivalent
+ to this.
+
+ """
+
+ STOP = enum.auto()
+ """Stop running any additional functions within the subgroup"""
+
+
+class DispatchPriority(enum.IntEnum):
+ """Indicate which of three sub-collections a function inside a
+ :class:`.PriorityDispatcher` should be placed.
+
+ .. versionadded:: 1.18.0
+
+ """
+
+ FIRST = 50
+ """Run the funciton in the first batch of functions (highest priority)"""
+
+ MEDIUM = 25
+ """Run the function at normal priority (this is the default)"""
+
+ LAST = 10
+ """Run the function in the last batch of functions"""
+
+
class Dispatcher:
- def __init__(self, uselist: bool = False) -> None:
+ def __init__(self) -> None:
self._registry: Dict[Tuple[Any, ...], Any] = {}
- self.uselist = uselist
def dispatch_for(
- self, target: Any, qualifier: str = "default"
+ self,
+ target: Any,
+ *,
+ qualifier: str = "default",
+ replace: bool = False,
) -> Callable[[_C], _C]:
def decorate(fn: _C) -> _C:
- if self.uselist:
- self._registry.setdefault((target, qualifier), []).append(fn)
- else:
- assert (target, qualifier) not in self._registry
- self._registry[(target, qualifier)] = fn
+ if (target, qualifier) in self._registry and not replace:
+ raise ValueError(
+ "Can not set dispatch function for object "
+ f"{target!r}: key already exists. To replace "
+ "existing function, use replace=True."
+ )
+ self._registry[(target, qualifier)] = fn
return fn
return decorate
@@ -290,42 +332,113 @@ def dispatch(self, obj: Any, qualifier: str = "default") -> Any:
else:
targets = type(obj).__mro__
- for spcls in targets:
- if qualifier != "default" and (spcls, qualifier) in self._registry:
- return self._fn_or_list(self._registry[(spcls, qualifier)])
- elif (spcls, "default") in self._registry:
- return self._fn_or_list(self._registry[(spcls, "default")])
+ if qualifier != "default":
+ qualifiers = [qualifier, "default"]
else:
- raise ValueError("no dispatch function for object: %s" % obj)
+ qualifiers = ["default"]
- def _fn_or_list(
- self, fn_or_list: Union[List[Callable[..., Any]], Callable[..., Any]]
- ) -> Callable[..., Any]:
- if self.uselist:
-
- def go(*arg: Any, **kw: Any) -> None:
- if TYPE_CHECKING:
- assert isinstance(fn_or_list, Sequence)
- for fn in fn_or_list:
- fn(*arg, **kw)
-
- return go
+ for spcls in targets:
+ for qualifier in qualifiers:
+ if (spcls, qualifier) in self._registry:
+ return self._registry[(spcls, qualifier)]
else:
- return fn_or_list # type: ignore
+ raise ValueError("no dispatch function for object: %s" % obj)
def branch(self) -> Dispatcher:
"""Return a copy of this dispatcher that is independently
writable."""
d = Dispatcher()
- if self.uselist:
- d._registry.update(
- (k, [fn for fn in self._registry[k]]) for k in self._registry
+ d._registry.update(self._registry)
+ return d
+
+
+class PriorityDispatcher:
+ """registers lists of functions at multiple levels of priorty and provides
+ a target to invoke them in priority order.
+
+ .. versionadded:: 1.18.0 - PriorityDispatcher replaces the job
+ of Dispatcher(uselist=True)
+
+ """
+
+ def __init__(self) -> None:
+ self._registry: dict[tuple[Any, ...], Any] = collections.defaultdict(
+ list
+ )
+
+ def dispatch_for(
+ self,
+ target: str,
+ *,
+ priority: DispatchPriority = DispatchPriority.MEDIUM,
+ qualifier: str = "default",
+ subgroup: str | None = None,
+ ) -> Callable[[_C], _C]:
+ """return a decorator callable that registers a function at a
+ given priority, with a given qualifier, to fire off for a given
+ subgroup.
+
+ It's important this remains as a decorator to support third party
+ plugins who are populating the dispatcher using that style.
+
+ """
+
+ def decorate(fn: _C) -> _C:
+ self._registry[(target, qualifier, priority)].append(
+ (fn, subgroup)
)
+ return fn
+
+ return decorate
+
+ def dispatch(
+ self, target: str, *, qualifier: str = "default"
+ ) -> Callable[..., None]:
+ """Provide a callable for the given target and qualifier."""
+
+ if qualifier != "default":
+ qualifiers = [qualifier, "default"]
else:
- d._registry.update(self._registry)
+ qualifiers = ["default"]
+
+ def go(*arg: Any, **kw: Any) -> Any:
+ results_by_subgroup: dict[str, PriorityDispatchResult] = {}
+ for priority in DispatchPriority:
+ for qualifier in qualifiers:
+ for fn, subgroup in self._registry.get(
+ (target, qualifier, priority), ()
+ ):
+ if (
+ results_by_subgroup.get(
+ subgroup, PriorityDispatchResult.CONTINUE
+ )
+ is PriorityDispatchResult.STOP
+ ):
+ continue
+
+ result = fn(*arg, **kw)
+ results_by_subgroup[subgroup] = result
+
+ return go
+
+ def branch(self) -> PriorityDispatcher:
+ """Return a copy of this dispatcher that is independently
+ writable."""
+
+ d = PriorityDispatcher()
+ d.populate_with(self)
return d
+ def populate_with(self, other: PriorityDispatcher) -> None:
+ """Populate this PriorityDispatcher with the contents of another one.
+
+ Additive, does not remove existing contents.
+ """
+ for k in other._registry:
+ new_list = other._registry[k]
+ self._registry[k].extend(new_list)
+
def not_none(value: Optional[_T]) -> _T:
assert value is not None
diff --git a/libs/alembic/util/messaging.py b/libs/alembic/util/messaging.py
index 6618fa7faa..4c08f16e7e 100644
--- a/libs/alembic/util/messaging.py
+++ b/libs/alembic/util/messaging.py
@@ -13,8 +13,6 @@
from sqlalchemy.engine import url
-from . import sqla_compat
-
log = logging.getLogger(__name__)
# disable "no handler found" errors
@@ -76,14 +74,17 @@ def err(message: str, quiet: bool = False) -> None:
def obfuscate_url_pw(input_url: str) -> str:
- u = url.make_url(input_url)
- return sqla_compat.url_render_as_string(u, hide_password=True) # type: ignore # noqa: E501
+ return url.make_url(input_url).render_as_string(hide_password=True)
def warn(msg: str, stacklevel: int = 2) -> None:
warnings.warn(msg, UserWarning, stacklevel=stacklevel)
+def warn_deprecated(msg: str, stacklevel: int = 2) -> None:
+ warnings.warn(msg, DeprecationWarning, stacklevel=stacklevel)
+
+
def msg(
msg: str, newline: bool = True, flush: bool = False, quiet: bool = False
) -> None:
diff --git a/libs/alembic/util/pyfiles.py b/libs/alembic/util/pyfiles.py
index 973bd458e5..135a42dce2 100644
--- a/libs/alembic/util/pyfiles.py
+++ b/libs/alembic/util/pyfiles.py
@@ -3,26 +3,33 @@
import atexit
from contextlib import ExitStack
import importlib
+from importlib import resources
import importlib.machinery
import importlib.util
import os
+import pathlib
import re
import tempfile
from types import ModuleType
from typing import Any
from typing import Optional
+from typing import Union
from mako import exceptions
from mako.template import Template
-from . import compat
from .exc import CommandError
def template_to_file(
- template_file: str, dest: str, output_encoding: str, **kw: Any
+ template_file: Union[str, os.PathLike[str]],
+ dest: Union[str, os.PathLike[str]],
+ output_encoding: str,
+ *,
+ append_with_newlines: bool = False,
+ **kw: Any,
) -> None:
- template = Template(filename=template_file)
+ template = Template(filename=_preserving_path_as_str(template_file))
try:
output = template.render_unicode(**kw).encode(output_encoding)
except:
@@ -38,11 +45,13 @@ def template_to_file(
"template-oriented traceback." % fname
)
else:
- with open(dest, "wb") as f:
+ with open(dest, "ab" if append_with_newlines else "wb") as f:
+ if append_with_newlines:
+ f.write("\n\n".encode(output_encoding))
f.write(output)
-def coerce_resource_to_filename(fname: str) -> str:
+def coerce_resource_to_filename(fname_or_resource: str) -> pathlib.Path:
"""Interpret a filename as either a filesystem location or as a package
resource.
@@ -50,48 +59,60 @@ def coerce_resource_to_filename(fname: str) -> str:
are interpreted as resources and coerced to a file location.
"""
- if not os.path.isabs(fname) and ":" in fname:
- tokens = fname.split(":")
+ # TODO: there seem to be zero tests for the package resource codepath
+ if not os.path.isabs(fname_or_resource) and ":" in fname_or_resource:
+ tokens = fname_or_resource.split(":")
# from https://importlib-resources.readthedocs.io/en/latest/migration.html#pkg-resources-resource-filename # noqa E501
file_manager = ExitStack()
atexit.register(file_manager.close)
- ref = compat.importlib_resources.files(tokens[0])
+ ref = resources.files(tokens[0])
for tok in tokens[1:]:
ref = ref / tok
- fname = file_manager.enter_context( # type: ignore[assignment]
- compat.importlib_resources.as_file(ref)
+ fname_or_resource = file_manager.enter_context( # type: ignore[assignment] # noqa: E501
+ resources.as_file(ref)
)
- return fname
+ return pathlib.Path(fname_or_resource)
-def pyc_file_from_path(path: str) -> Optional[str]:
+def pyc_file_from_path(
+ path: Union[str, os.PathLike[str]],
+) -> Optional[pathlib.Path]:
"""Given a python source path, locate the .pyc."""
- candidate = importlib.util.cache_from_source(path)
- if os.path.exists(candidate):
+ pathpath = pathlib.Path(path)
+ candidate = pathlib.Path(
+ importlib.util.cache_from_source(pathpath.as_posix())
+ )
+ if candidate.exists():
return candidate
# even for pep3147, fall back to the old way of finding .pyc files,
# to support sourceless operation
- filepath, ext = os.path.splitext(path)
+ ext = pathpath.suffix
for ext in importlib.machinery.BYTECODE_SUFFIXES:
- if os.path.exists(filepath + ext):
- return filepath + ext
+ if pathpath.with_suffix(ext).exists():
+ return pathpath.with_suffix(ext)
else:
return None
-def load_python_file(dir_: str, filename: str) -> ModuleType:
+def load_python_file(
+ dir_: Union[str, os.PathLike[str]], filename: Union[str, os.PathLike[str]]
+) -> ModuleType:
"""Load a file from the given path as a Python module."""
+ dir_ = pathlib.Path(dir_)
+ filename_as_path = pathlib.Path(filename)
+ filename = filename_as_path.name
+
module_id = re.sub(r"\W", "_", filename)
- path = os.path.join(dir_, filename)
- _, ext = os.path.splitext(filename)
+ path = dir_ / filename
+ ext = path.suffix
if ext == ".py":
- if os.path.exists(path):
+ if path.exists():
module = load_module_py(module_id, path)
else:
pyc_path = pyc_file_from_path(path)
@@ -106,9 +127,27 @@ def load_python_file(dir_: str, filename: str) -> ModuleType:
return module
-def load_module_py(module_id: str, path: str) -> ModuleType:
+def load_module_py(
+ module_id: str, path: Union[str, os.PathLike[str]]
+) -> ModuleType:
spec = importlib.util.spec_from_file_location(module_id, path)
assert spec
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module) # type: ignore
return module
+
+
+def _preserving_path_as_str(path: Union[str, os.PathLike[str]]) -> str:
+ """receive str/pathlike and return a string.
+
+ Does not convert an incoming string path to a Path first, to help with
+ unit tests that are doing string path round trips without OS-specific
+ processing if not necessary.
+
+ """
+ if isinstance(path, str):
+ return path
+ elif isinstance(path, pathlib.PurePath):
+ return str(path)
+ else:
+ return str(pathlib.Path(path))
diff --git a/libs/alembic/util/sqla_compat.py b/libs/alembic/util/sqla_compat.py
index d4ed0fdd59..ff2f2c9390 100644
--- a/libs/alembic/util/sqla_compat.py
+++ b/libs/alembic/util/sqla_compat.py
@@ -10,7 +10,6 @@
from typing import Dict
from typing import Iterable
from typing import Iterator
-from typing import Mapping
from typing import Optional
from typing import Protocol
from typing import Set
@@ -20,11 +19,9 @@
from typing import Union
from sqlalchemy import __version__
-from sqlalchemy import inspect
from sqlalchemy import schema
from sqlalchemy import sql
from sqlalchemy import types as sqltypes
-from sqlalchemy.engine import url
from sqlalchemy.schema import CheckConstraint
from sqlalchemy.schema import Column
from sqlalchemy.schema import ForeignKeyConstraint
@@ -32,28 +29,25 @@
from sqlalchemy.sql.base import DialectKWArgs
from sqlalchemy.sql.elements import BindParameter
from sqlalchemy.sql.elements import ColumnClause
-from sqlalchemy.sql.elements import quoted_name
from sqlalchemy.sql.elements import TextClause
from sqlalchemy.sql.elements import UnaryExpression
+from sqlalchemy.sql.naming import _NONE_NAME as _NONE_NAME # type: ignore[attr-defined] # noqa: E501
from sqlalchemy.sql.visitors import traverse
from typing_extensions import TypeGuard
if TYPE_CHECKING:
from sqlalchemy import ClauseElement
+ from sqlalchemy import Identity
from sqlalchemy import Index
from sqlalchemy import Table
from sqlalchemy.engine import Connection
from sqlalchemy.engine import Dialect
from sqlalchemy.engine import Transaction
- from sqlalchemy.engine.reflection import Inspector
from sqlalchemy.sql.base import ColumnCollection
from sqlalchemy.sql.compiler import SQLCompiler
- from sqlalchemy.sql.dml import Insert
from sqlalchemy.sql.elements import ColumnElement
from sqlalchemy.sql.schema import Constraint
from sqlalchemy.sql.schema import SchemaItem
- from sqlalchemy.sql.selectable import Select
- from sqlalchemy.sql.selectable import TableClause
_CE = TypeVar("_CE", bound=Union["ColumnElement[Any]", "SchemaItem"])
@@ -72,24 +66,14 @@ def _safe_int(value: str) -> Union[int, str]:
_vers = tuple(
[_safe_int(x) for x in re.findall(r"(\d+|[abc]\d)", __version__)]
)
-sqla_13 = _vers >= (1, 3)
-sqla_14 = _vers >= (1, 4)
# https://docs.sqlalchemy.org/en/latest/changelog/changelog_14.html#change-0c6e0cc67dfe6fac5164720e57ef307d
sqla_14_18 = _vers >= (1, 4, 18)
sqla_14_26 = _vers >= (1, 4, 26)
sqla_2 = _vers >= (2,)
+sqla_2_0_25 = _vers >= (2, 25)
+sqla_2_1 = _vers >= (2, 1)
sqlalchemy_version = __version__
-try:
- from sqlalchemy.sql.naming import _NONE_NAME as _NONE_NAME # type: ignore[attr-defined] # noqa: E501
-except ImportError:
- from sqlalchemy.sql.elements import _NONE_NAME as _NONE_NAME # type: ignore # noqa: E501
-
-
-class _Unsupported:
- "Placeholder for unsupported SQLAlchemy classes"
-
-
if TYPE_CHECKING:
def compiles(
@@ -97,74 +81,52 @@ def compiles(
) -> Callable[[_CompilerProtocol], _CompilerProtocol]: ...
else:
- from sqlalchemy.ext.compiler import compiles
-
-try:
- from sqlalchemy import Computed as Computed
-except ImportError:
- if not TYPE_CHECKING:
+ from sqlalchemy.ext.compiler import compiles # noqa: I100,I202
- class Computed(_Unsupported):
- pass
- has_computed = False
- has_computed_reflection = False
-else:
- has_computed = True
- has_computed_reflection = _vers >= (1, 3, 16)
+identity_has_dialect_kwargs = issubclass(schema.Identity, DialectKWArgs)
-try:
- from sqlalchemy import Identity as Identity
-except ImportError:
- if not TYPE_CHECKING:
- class Identity(_Unsupported):
- pass
+def _get_identity_options_dict(
+ identity: Union[Identity, schema.Sequence, None],
+ dialect_kwargs: bool = False,
+) -> Dict[str, Any]:
+ if identity is None:
+ return {}
+ elif identity_has_dialect_kwargs:
+ assert hasattr(identity, "_as_dict")
+ as_dict = identity._as_dict()
+ if dialect_kwargs:
+ assert isinstance(identity, DialectKWArgs)
+ as_dict.update(identity.dialect_kwargs)
+ else:
+ as_dict = {}
+ if isinstance(identity, schema.Identity):
+ # always=None means something different than always=False
+ as_dict["always"] = identity.always
+ if identity.on_null is not None:
+ as_dict["on_null"] = identity.on_null
+ # attributes common to Identity and Sequence
+ attrs = (
+ "start",
+ "increment",
+ "minvalue",
+ "maxvalue",
+ "nominvalue",
+ "nomaxvalue",
+ "cycle",
+ "cache",
+ "order",
+ )
+ as_dict.update(
+ {
+ key: getattr(identity, key, None)
+ for key in attrs
+ if getattr(identity, key, None) is not None
+ }
+ )
+ return as_dict
- has_identity = False
-else:
- identity_has_dialect_kwargs = issubclass(Identity, DialectKWArgs)
-
- def _get_identity_options_dict(
- identity: Union[Identity, schema.Sequence, None],
- dialect_kwargs: bool = False,
- ) -> Dict[str, Any]:
- if identity is None:
- return {}
- elif identity_has_dialect_kwargs:
- as_dict = identity._as_dict() # type: ignore
- if dialect_kwargs:
- assert isinstance(identity, DialectKWArgs)
- as_dict.update(identity.dialect_kwargs)
- else:
- as_dict = {}
- if isinstance(identity, Identity):
- # always=None means something different than always=False
- as_dict["always"] = identity.always
- if identity.on_null is not None:
- as_dict["on_null"] = identity.on_null
- # attributes common to Identity and Sequence
- attrs = (
- "start",
- "increment",
- "minvalue",
- "maxvalue",
- "nominvalue",
- "nomaxvalue",
- "cycle",
- "cache",
- "order",
- )
- as_dict.update(
- {
- key: getattr(identity, key, None)
- for key in attrs
- if getattr(identity, key, None) is not None
- }
- )
- return as_dict
-
- has_identity = True
if sqla_2:
from sqlalchemy.sql.base import _NoneName
@@ -173,7 +135,6 @@ def _get_identity_options_dict(
_ConstraintName = Union[None, str, _NoneName]
-
_ConstraintNameDefined = Union[str, _NoneName]
@@ -183,15 +144,11 @@ def constraint_name_defined(
return name is _NONE_NAME or isinstance(name, (str, _NoneName))
-def constraint_name_string(
- name: _ConstraintName,
-) -> TypeGuard[str]:
+def constraint_name_string(name: _ConstraintName) -> TypeGuard[str]:
return isinstance(name, str)
-def constraint_name_or_none(
- name: _ConstraintName,
-) -> Optional[str]:
+def constraint_name_or_none(name: _ConstraintName) -> Optional[str]:
return name if constraint_name_string(name) else None
@@ -221,17 +178,10 @@ def _ensure_scope_for_ddl(
yield
-def url_render_as_string(url, hide_password=True):
- if sqla_14:
- return url.render_as_string(hide_password=hide_password)
- else:
- return url.__to_string__(hide_password=hide_password)
-
-
def _safe_begin_connection_transaction(
connection: Connection,
) -> Transaction:
- transaction = _get_connection_transaction(connection)
+ transaction = connection.get_transaction()
if transaction:
return transaction
else:
@@ -241,7 +191,7 @@ def _safe_begin_connection_transaction(
def _safe_commit_connection_transaction(
connection: Connection,
) -> None:
- transaction = _get_connection_transaction(connection)
+ transaction = connection.get_transaction()
if transaction:
transaction.commit()
@@ -249,7 +199,7 @@ def _safe_commit_connection_transaction(
def _safe_rollback_connection_transaction(
connection: Connection,
) -> None:
- transaction = _get_connection_transaction(connection)
+ transaction = connection.get_transaction()
if transaction:
transaction.rollback()
@@ -275,65 +225,29 @@ def _copy(schema_item: _CE, **kw) -> _CE:
return schema_item.copy(**kw) # type: ignore[union-attr]
-def _get_connection_transaction(
- connection: Connection,
-) -> Optional[Transaction]:
- if sqla_14:
- return connection.get_transaction()
- else:
- r = connection._root # type: ignore[attr-defined]
- return r._Connection__transaction
-
-
-def _create_url(*arg, **kw) -> url.URL:
- if hasattr(url.URL, "create"):
- return url.URL.create(*arg, **kw)
- else:
- return url.URL(*arg, **kw)
-
-
def _connectable_has_table(
connectable: Connection, tablename: str, schemaname: Union[str, None]
) -> bool:
- if sqla_14:
- return inspect(connectable).has_table(tablename, schemaname)
- else:
- return connectable.dialect.has_table(
- connectable, tablename, schemaname
- )
+ return connectable.dialect.has_table(connectable, tablename, schemaname)
def _exec_on_inspector(inspector, statement, **params):
- if sqla_14:
- with inspector._operation_context() as conn:
- return conn.execute(statement, params)
- else:
- return inspector.bind.execute(statement, params)
+ with inspector._operation_context() as conn:
+ return conn.execute(statement, params)
def _nullability_might_be_unset(metadata_column):
- if not sqla_14:
- return metadata_column.nullable
- else:
- from sqlalchemy.sql import schema
+ from sqlalchemy.sql import schema
- return (
- metadata_column._user_defined_nullable is schema.NULL_UNSPECIFIED
- )
+ return metadata_column._user_defined_nullable is schema.NULL_UNSPECIFIED
def _server_default_is_computed(*server_default) -> bool:
- if not has_computed:
- return False
- else:
- return any(isinstance(sd, Computed) for sd in server_default)
+ return any(isinstance(sd, schema.Computed) for sd in server_default)
def _server_default_is_identity(*server_default) -> bool:
- if not sqla_14:
- return False
- else:
- return any(isinstance(sd, Identity) for sd in server_default)
+ return any(isinstance(sd, schema.Identity) for sd in server_default)
def _table_for_constraint(constraint: Constraint) -> Table:
@@ -354,15 +268,6 @@ def _columns_for_constraint(constraint):
return list(constraint.columns)
-def _reflect_table(inspector: Inspector, table: Table) -> None:
- if sqla_14:
- return inspector.reflect_table(table, None)
- else:
- return inspector.reflecttable( # type: ignore[attr-defined]
- table, None
- )
-
-
def _resolve_for_variant(type_, dialect):
if _type_has_variants(type_):
base_type, mapping = _get_variant_mapping(type_)
@@ -371,7 +276,7 @@ def _resolve_for_variant(type_, dialect):
return type_
-if hasattr(sqltypes.TypeEngine, "_variant_mapping"):
+if hasattr(sqltypes.TypeEngine, "_variant_mapping"): # 2.0
def _type_has_variants(type_):
return bool(type_._variant_mapping)
@@ -388,6 +293,13 @@ def _get_variant_mapping(type_):
return type_.impl, type_.mapping
+def _get_table_key(name: str, schema: Optional[str]) -> str:
+ if schema is None:
+ return name
+ else:
+ return schema + "." + name
+
+
def _fk_spec(constraint: ForeignKeyConstraint) -> Any:
if TYPE_CHECKING:
assert constraint.columns is not None
@@ -549,103 +461,32 @@ def _render_literal_bindparam(
return compiler.render_literal_bindparam(element, **kw)
-def _column_kwargs(col: Column) -> Mapping:
- if sqla_13:
- return col.kwargs
- else:
- return {}
-
-
def _get_constraint_final_name(
constraint: Union[Index, Constraint], dialect: Optional[Dialect]
) -> Optional[str]:
if constraint.name is None:
return None
assert dialect is not None
- if sqla_14:
- # for SQLAlchemy 1.4 we would like to have the option to expand
- # the use of "deferred" names for constraints as well as to have
- # some flexibility with "None" name and similar; make use of new
- # SQLAlchemy API to return what would be the final compiled form of
- # the name for this dialect.
- return dialect.identifier_preparer.format_constraint(
- constraint, _alembic_quote=False
- )
- else:
- # prior to SQLAlchemy 1.4, work around quoting logic to get at the
- # final compiled name without quotes.
- if hasattr(constraint.name, "quote"):
- # might be quoted_name, might be truncated_name, keep it the
- # same
- quoted_name_cls: type = type(constraint.name)
- else:
- quoted_name_cls = quoted_name
-
- new_name = quoted_name_cls(str(constraint.name), quote=False)
- constraint = constraint.__class__(name=new_name)
-
- if isinstance(constraint, schema.Index):
- # name should not be quoted.
- d = dialect.ddl_compiler(dialect, None) # type: ignore[arg-type]
- return d._prepared_index_name(constraint)
- else:
- # name should not be quoted.
- return dialect.identifier_preparer.format_constraint(constraint)
+ # for SQLAlchemy 1.4 we would like to have the option to expand
+ # the use of "deferred" names for constraints as well as to have
+ # some flexibility with "None" name and similar; make use of new
+ # SQLAlchemy API to return what would be the final compiled form of
+ # the name for this dialect.
+ return dialect.identifier_preparer.format_constraint(
+ constraint, _alembic_quote=False
+ )
def _constraint_is_named(
constraint: Union[Constraint, Index], dialect: Optional[Dialect]
) -> bool:
- if sqla_14:
- if constraint.name is None:
- return False
- assert dialect is not None
- name = dialect.identifier_preparer.format_constraint(
- constraint, _alembic_quote=False
- )
- return name is not None
- else:
- return constraint.name is not None
-
-
-def _is_mariadb(mysql_dialect: Dialect) -> bool:
- if sqla_14:
- return mysql_dialect.is_mariadb # type: ignore[attr-defined]
- else:
- return bool(
- mysql_dialect.server_version_info
- and mysql_dialect._is_mariadb # type: ignore[attr-defined]
- )
-
-
-def _mariadb_normalized_version_info(mysql_dialect):
- return mysql_dialect._mariadb_normalized_version_info
-
-
-def _insert_inline(table: Union[TableClause, Table]) -> Insert:
- if sqla_14:
- return table.insert().inline()
- else:
- return table.insert(inline=True) # type: ignore[call-arg]
-
-
-if sqla_14:
- from sqlalchemy import create_mock_engine
-
- # weird mypy workaround
- from sqlalchemy import select as _sa_select
-
- _select = _sa_select
-else:
- from sqlalchemy import create_engine
-
- def create_mock_engine(url, executor, **kw): # type: ignore[misc]
- return create_engine(
- "postgresql://", strategy="mock", executor=executor
- )
-
- def _select(*columns, **kw) -> Select:
- return sql.select(list(columns), **kw) # type: ignore[call-overload]
+ if constraint.name is None:
+ return False
+ assert dialect is not None
+ name = dialect.identifier_preparer.format_constraint(
+ constraint, _alembic_quote=False
+ )
+ return name is not None
def is_expression_index(index: Index) -> bool:
@@ -661,3 +502,9 @@ def is_expression(expr: Any) -> bool:
if not isinstance(expr, ColumnClause) or expr.is_literal:
return True
return False
+
+
+def _inherit_schema_deprecated() -> bool:
+ # at some point in 2.1 inherit_schema was replaced with a property
+ # so that's preset at the class level, while before it wasn't.
+ return sqla_2_1 and hasattr(sqltypes.Enum, "inherit_schema")
diff --git a/libs/aniso8601-10.0.0.dist-info/METADATA b/libs/aniso8601-10.0.0.dist-info/METADATA
deleted file mode 100644
index 01ae669726..0000000000
--- a/libs/aniso8601-10.0.0.dist-info/METADATA
+++ /dev/null
@@ -1,509 +0,0 @@
-Metadata-Version: 2.1
-Name: aniso8601
-Version: 10.0.0
-Summary: A library for parsing ISO 8601 strings.
-Home-page: https://bitbucket.org/nielsenb/aniso8601
-Author: Brandon Nielsen
-Author-email: nielsenb@jetfuse.net
-Project-URL: Documentation, https://aniso8601.readthedocs.io/
-Project-URL: Source, https://bitbucket.org/nielsenb/aniso8601
-Project-URL: Tracker, https://bitbucket.org/nielsenb/aniso8601/issues
-Keywords: iso8601 parser
-Classifier: Development Status :: 5 - Production/Stable
-Classifier: Intended Audience :: Developers
-Classifier: License :: OSI Approved :: BSD License
-Classifier: Operating System :: OS Independent
-Classifier: Programming Language :: Python
-Classifier: Programming Language :: Python :: 2
-Classifier: Programming Language :: Python :: 2.7
-Classifier: Programming Language :: Python :: 3
-Classifier: Programming Language :: Python :: 3.9
-Classifier: Programming Language :: Python :: 3.10
-Classifier: Programming Language :: Python :: 3.11
-Classifier: Programming Language :: Python :: 3.12
-Classifier: Programming Language :: Python :: 3.13
-Classifier: Topic :: Software Development :: Libraries :: Python Modules
-Description-Content-Type: text/x-rst
-License-File: LICENSE
-Provides-Extra: dev
-Requires-Dist: black ; extra == 'dev'
-Requires-Dist: coverage ; extra == 'dev'
-Requires-Dist: isort ; extra == 'dev'
-Requires-Dist: pre-commit ; extra == 'dev'
-Requires-Dist: pyenchant ; extra == 'dev'
-Requires-Dist: pylint ; extra == 'dev'
-
-aniso8601
-=========
-
-Another ISO 8601 parser for Python
-----------------------------------
-
-Features
-========
-* Pure Python implementation
-* Logical behavior
-
- - Parse a time, get a `datetime.time `_
- - Parse a date, get a `datetime.date `_
- - Parse a datetime, get a `datetime.datetime `_
- - Parse a duration, get a `datetime.timedelta `_
- - Parse an interval, get a tuple of dates or datetimes
- - Parse a repeating interval, get a date or datetime `generator `_
-
-* UTC offset represented as fixed-offset tzinfo
-* Parser separate from representation, allowing parsing to different datetime representations (see `Builders`_)
-* No regular expressions
-
-Installation
-============
-
-The recommended installation method is to use pip::
-
- $ pip install aniso8601
-
-Alternatively, you can download the source (git repository hosted at `Bitbucket `_) and install directly::
-
- $ python setup.py install
-
-Use
-===
-
-Parsing datetimes
------------------
-
-*Consider* `datetime.datetime.fromisoformat `_ *for basic ISO 8601 datetime parsing*
-
-To parse a typical ISO 8601 datetime string::
-
- >>> import aniso8601
- >>> aniso8601.parse_datetime('1977-06-10T12:00:00Z')
- datetime.datetime(1977, 6, 10, 12, 0, tzinfo=+0:00:00 UTC)
-
-Alternative delimiters can be specified, for example, a space::
-
- >>> aniso8601.parse_datetime('1977-06-10 12:00:00Z', delimiter=' ')
- datetime.datetime(1977, 6, 10, 12, 0, tzinfo=+0:00:00 UTC)
-
-UTC offsets are supported::
-
- >>> aniso8601.parse_datetime('1979-06-05T08:00:00-08:00')
- datetime.datetime(1979, 6, 5, 8, 0, tzinfo=-8:00:00 UTC)
-
-If a UTC offset is not specified, the returned datetime will be naive::
-
- >>> aniso8601.parse_datetime('1983-01-22T08:00:00')
- datetime.datetime(1983, 1, 22, 8, 0)
-
-Leap seconds are currently not supported and attempting to parse one raises a :code:`LeapSecondError`::
-
- >>> aniso8601.parse_datetime('2018-03-06T23:59:60')
- Traceback (most recent call last):
- File "", line 1, in
- File "/home/nielsenb/Jetfuse/aniso8601/aniso8601/aniso8601/time.py", line 196, in parse_datetime
- return builder.build_datetime(datepart, timepart)
- File "/home/nielsenb/Jetfuse/aniso8601/aniso8601/aniso8601/builders/python.py", line 237, in build_datetime
- cls._build_object(time))
- File "/home/nielsenb/Jetfuse/aniso8601/aniso8601/aniso8601/builders/__init__.py", line 336, in _build_object
- return cls.build_time(hh=parsetuple.hh, mm=parsetuple.mm,
- File "/home/nielsenb/Jetfuse/aniso8601/aniso8601/aniso8601/builders/python.py", line 191, in build_time
- hh, mm, ss, tz = cls.range_check_time(hh, mm, ss, tz)
- File "/home/nielsenb/Jetfuse/aniso8601/aniso8601/aniso8601/builders/__init__.py", line 266, in range_check_time
- raise LeapSecondError('Leap seconds are not supported.')
- aniso8601.exceptions.LeapSecondError: Leap seconds are not supported.
-
-To get the resolution of an ISO 8601 datetime string::
-
- >>> aniso8601.get_datetime_resolution('1977-06-10T12:00:00Z') == aniso8601.resolution.TimeResolution.Seconds
- True
- >>> aniso8601.get_datetime_resolution('1977-06-10T12:00') == aniso8601.resolution.TimeResolution.Minutes
- True
- >>> aniso8601.get_datetime_resolution('1977-06-10T12') == aniso8601.resolution.TimeResolution.Hours
- True
-
-Note that datetime resolutions map to :code:`TimeResolution` as a valid datetime must have at least one time member so the resolution mapping is equivalent.
-
-Parsing dates
--------------
-
-*Consider* `datetime.date.fromisoformat